diff --git a/package.json b/package.json index 403fd1e..f531465 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@grammyjs/conversations": "^1.2.0", "@grammyjs/menu": "^1.2.1", "@grammyjs/storage-redis": "^2.4.1", + "@loskir/styled-qr-code-node": "^1.5.1", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", "@vercel/kv": "^1.0.1", diff --git a/src/chains/conversations.config.ts b/src/chains/conversations.config.ts index c59415a..a0ecbda 100644 --- a/src/chains/conversations.config.ts +++ b/src/chains/conversations.config.ts @@ -13,4 +13,5 @@ export enum ConversationId { ImportNewWallet = 'importNewWallet', CheckCurrentWalletForRefund = 'checkCurrentWalletForRefund', CheckProvidedAddressForRefund = 'checkProvidedAddressForRefund', + ChangeReferrer = 'change-referrer', } diff --git a/src/chains/referral/config.ts b/src/chains/referral/config.ts new file mode 100644 index 0000000..a963177 --- /dev/null +++ b/src/chains/referral/config.ts @@ -0,0 +1,63 @@ +import { Options } from '@loskir/styled-qr-code-node'; + +/** + * This is a timestamp in ms length. + */ +export const REFERRAL_ID_LENGTH = 13; + +export const PARAMS_SEPARATOR = '_'; +export const REF_PARAM_KEY = 'ref'; + +export enum ReferralRedisKeyFirstPart { + Referrer = 'referrer', + Referrals = 'referrals', + ReferralsTrades = 'referrals_trades', +} + +export enum ReferrerFieldKey { + PublicKey = 'publicKey', +} + +export const REFERRAL_QR_CODE_CONFIG: Options = { + width: 1000, + height: 1000, + margin: 60, + qrOptions: { typeNumber: 0, mode: 'Byte', errorCorrectionLevel: 'Q' }, + imageOptions: { hideBackgroundDots: true, imageSize: 0.3, margin: 8 }, + dotsOptions: { + type: 'rounded', + color: '#6a1a4c', + gradient: { + type: 'linear', + rotation: 0.7853981633974483, + colorStops: [ + { offset: 0, color: '#e9755d' }, + { offset: 1, color: '#5f54ff' }, + ], + }, + }, + backgroundOptions: { color: '#ffffff' }, + cornersSquareOptions: { + type: 'extra-rounded', + color: '#f3c0f1', + gradient: { + type: 'linear', + rotation: 3.141592653589793, + colorStops: [ + { offset: 0, color: '#eb755d' }, + { offset: 1, color: '#5d53f6' }, + ], + }, + }, + cornersDotOptions: { + color: '#f3c0f1', + gradient: { + type: 'linear', + rotation: 3.141592653589793, + colorStops: [ + { offset: 0, color: '#eb755d' }, + { offset: 1, color: '#5f53f5' }, + ], + }, + }, +}; diff --git a/src/chains/referral/conversations/change-referrer.ts b/src/chains/referral/conversations/change-referrer.ts new file mode 100644 index 0000000..4debe6e --- /dev/null +++ b/src/chains/referral/conversations/change-referrer.ts @@ -0,0 +1,72 @@ +import confirmWithCloseKeyboard from '../../../inline-keyboards/mixed/confirm-with-close'; +import { retryAndGoHomeButtonsData } from '../../../inline-keyboards/retryConversationButtonsFactory'; +import backToMainReferralMenu from '../../../inline-keyboards/settings/referral/back'; +import { BotContext, MyConversation } from '../../../types'; +import { CallbackQueryData } from '../../../types/callback-queries-data'; +import { ConversationId } from '../../conversations.config'; +import { reactOnUnexpectedBehaviour } from '../../utils'; +import { addReferralToReferrer, removeReferralFromReferrer } from '../redis/utils'; +import { setReferrerPublicKey } from '../utils'; +import { askForReferrerId } from './utils'; + +export async function changeReferrer(conversation: MyConversation, ctx: BotContext) { + const retryButton = retryAndGoHomeButtonsData[ConversationId.ChangeReferrer]; + + let newReferrerId; + + if (conversation.session.referral.referrer.newId !== null) { + newReferrerId = conversation.session.referral.referrer.newId; + } else { + newReferrerId = await askForReferrerId({ conversation, ctx }); + } + + const userHasNoReferrer = conversation.session.referral.referrer.id === null; + + await ctx.reply( + `You are going to ${userHasNoReferrer ? 'set' : 'change'} your referrer to ${newReferrerId}`, + { + reply_markup: confirmWithCloseKeyboard, + parse_mode: 'HTML', + }, + ); + + const confirmContext = await conversation.wait(); + const callbackQueryData = confirmContext.callbackQuery?.data; + + if (callbackQueryData === undefined) { + await reactOnUnexpectedBehaviour(confirmContext, retryButton, 'referrer changing'); + return; + } else if (callbackQueryData !== CallbackQueryData.Confirm) { + await confirmContext.answerCallbackQuery(); + await ctx.reply('Referrer is not changed.', { reply_markup: backToMainReferralMenu, parse_mode: 'HTML' }); + + return; + } + + await confirmContext.answerCallbackQuery(); + + const changingMessage = await ctx.reply(`${userHasNoReferrer ? 'Setting' : 'Changing'} referrer...`, { + parse_mode: 'HTML', + }); + + await setReferrerPublicKey({ ctx, conversation, referrerId: newReferrerId }); + await addReferralToReferrer({ referralId: conversation.session.referral.referralId, referrerId: newReferrerId }); + + if (conversation.session.referral.referrer.id !== null) { + await removeReferralFromReferrer({ + referralId: conversation.session.referral.referralId, + referrerId: conversation.session.referral.referrer.id, + }); + } + + conversation.session.referral.referrer.id = newReferrerId; + + await ctx.api.editMessageText( + changingMessage.chat.id, + changingMessage.message_id, + `βœ… Referrer is successfully ${userHasNoReferrer ? 'set' : 'changed'}!`, + { reply_markup: backToMainReferralMenu, parse_mode: 'HTML' }, + ); + + return; +} diff --git a/src/chains/referral/conversations/utils.ts b/src/chains/referral/conversations/utils.ts new file mode 100644 index 0000000..7829d39 --- /dev/null +++ b/src/chains/referral/conversations/utils.ts @@ -0,0 +1,79 @@ +import { EndConversationError } from '../../../errors/end-conversation.error'; +import closeConversation from '../../../inline-keyboards/closeConversation'; +import { retryAndGoHomeButtonsData } from '../../../inline-keyboards/retryConversationButtonsFactory'; +import { BotContext, MyConversation } from '../../../types'; +import { CallbackQueryData } from '../../../types/callback-queries-data'; +import { ConversationId } from '../../conversations.config'; +import { reactOnUnexpectedBehaviour } from '../../utils'; +import { getExistingReferralIds } from '../redis/utils'; + +export async function askForReferrerId({ + conversation, + ctx, +}: { + conversation: MyConversation; + ctx: BotContext; +}): Promise { + const retryButton = retryAndGoHomeButtonsData[ConversationId.ChangeReferrer]; + + await ctx.reply( + 'Please, enter your new referrer id.\n\nHint: you also can change your ' + + 'referrer by following his/her referral link or referral QR-code.', + { + reply_markup: closeConversation, + parse_mode: 'HTML', + }, + ); + + const newReferrerIdContext = await conversation.wait(); + const callbackQueryData = newReferrerIdContext.callbackQuery?.data; + const newReferrerId = newReferrerIdContext.msg?.text?.trim(); + + if (callbackQueryData === CallbackQueryData.Cancel) { + await conversation.skip(); + throw new EndConversationError(); + } else if (callbackQueryData !== undefined || newReferrerId === undefined) { + await reactOnUnexpectedBehaviour(newReferrerIdContext, retryButton, 'referrer changing'); + throw new EndConversationError(); + } + + if (newReferrerId === ctx.session.referral.referrer.id) { + await ctx.reply( + "The referrer id you've entered is your current referrer id. Please, enter a new one.", + { + reply_markup: closeConversation, + parse_mode: 'HTML', + }, + ); + + await conversation.skip({ drop: true }); + } + + const existingReferrerIds = await conversation.external(() => getExistingReferralIds()); + + if (!existingReferrerIds.includes(newReferrerId)) { + await ctx.reply( + 'Cannot find the user with this referral id. Please, enter a valid one or contact support.', + { + reply_markup: closeConversation, + parse_mode: 'HTML', + }, + ); + + await conversation.skip({ drop: true }); + } + + if (newReferrerId === ctx.session.referral.referralId) { + await ctx.reply( + "The referrer id you've entered is your referral id. Please, enter the valid referrer id.", + { + reply_markup: closeConversation, + parse_mode: 'HTML', + }, + ); + + await conversation.skip({ drop: true }); + } + + return newReferrerId; +} diff --git a/src/chains/referral/handle-referral-follow.ts b/src/chains/referral/handle-referral-follow.ts new file mode 100644 index 0000000..c11dd60 --- /dev/null +++ b/src/chains/referral/handle-referral-follow.ts @@ -0,0 +1,42 @@ +import goHome from '../../inline-keyboards/goHome'; +import { BotContext } from '../../types'; +import { ConversationId } from '../conversations.config'; +import { addReferralToReferrer } from './redis/utils'; +import { setReferrerPublicKey } from './utils'; + +export async function handleReferralFollow({ + ctx, + referrerId, +}: { + ctx: BotContext; + referrerId: string; +}): Promise<{ referrerIsChanging: boolean }> { + const currentReferrerId = ctx.session.referral.referrer.id; + + // Just silent exit, when user tries to change his referrer to his current referrer + if (referrerId === currentReferrerId) { + return { referrerIsChanging: false }; + } + + // Prevent a scenario, when user tries to become a referrer of himself + if (referrerId === ctx.session.referral.referralId) { + await ctx.reply('❌ You cannot be a referrer of yourself.', { reply_markup: goHome, parse_mode: 'HTML' }); + + return { referrerIsChanging: false }; + } + + if (currentReferrerId === null) { + ctx.session.referral.referrer.id = referrerId; + + setReferrerPublicKey({ ctx, referrerId }); + addReferralToReferrer({ referralId: ctx.session.referral.referralId, referrerId }); + } else { + ctx.session.referral.referrer.newId = referrerId; + + await ctx.conversation.enter(ConversationId.ChangeReferrer); + + return { referrerIsChanging: true }; + } + + return { referrerIsChanging: false }; +} diff --git a/src/chains/referral/pages/link.ts b/src/chains/referral/pages/link.ts new file mode 100644 index 0000000..e63394c --- /dev/null +++ b/src/chains/referral/pages/link.ts @@ -0,0 +1,12 @@ +import shareMyReferralLinkMenu from '../../../menu/referral/share-link'; +import { BotContext } from '../../../types'; +import { getReferralLink } from '../utils'; + +export async function showReferralLinkPage(ctx: BotContext) { + const referralLink = getReferralLink(ctx.session.referral.referralId); + + await ctx.reply( + `βœ‰ Your referral link:\n${referralLink}\n\nJust send it to your friend and enjoy 😎`, + { reply_markup: shareMyReferralLinkMenu, parse_mode: 'HTML' }, + ); +} diff --git a/src/chains/referral/pages/main.ts b/src/chains/referral/pages/main.ts new file mode 100644 index 0000000..5191f92 --- /dev/null +++ b/src/chains/referral/pages/main.ts @@ -0,0 +1,15 @@ +import referralMenu from '../../../menu/referral/referral'; +import { BotContext } from '../../../types'; + +export async function showMainReferralPage(ctx: BotContext) { + const referralId = ctx.session.referral.referralId; + + await ctx.reply( + 'πŸš€ Wanna share your referral link, QR-code, or change your referrer? ' + + `Then you are at the right place 😎\n\nYour referral id: ${referralId}\n\n` + + `Hint: referral id is your unique referral identificator, ` + + `that you certainly can share with your friends, but for better experience use the ` + + `Share button πŸ˜‰`, + { reply_markup: referralMenu, parse_mode: 'HTML' }, + ); +} diff --git a/src/chains/referral/pages/my-referrals.ts b/src/chains/referral/pages/my-referrals.ts new file mode 100644 index 0000000..4ddde53 --- /dev/null +++ b/src/chains/referral/pages/my-referrals.ts @@ -0,0 +1,86 @@ +import BigNumber from 'bignumber.js'; +import shareMyReferralLinkMenu from '../../../menu/referral/share-link'; +import { BotContext } from '../../../types'; +import { CoinAmount, CoinType } from '../../types'; +import { getReferralsTrades, getUserReferrals } from '../redis/utils'; +import { ReferralTradeData } from '../types'; +import { getCoinManager } from '../../sui.functions'; +import { LONG_SUI_COIN_TYPE, isSuiCoinType } from '@avernikoz/rinbot-sui-sdk'; + +export async function showMyReferralsPage(ctx: BotContext) { + const loadingMessage = await ctx.reply('Loading...', { parse_mode: 'HTML' }); + + const userReferrals = await getUserReferrals(ctx.session.referral.referralId); + const referralTrades: ReferralTradeData[] = await getReferralsTrades(ctx.session.referral.referralId); + + console.debug('referralTrades:', referralTrades); + + if (userReferrals.length === 0 && referralTrades.length === 0) { + await ctx.api.editMessageText(loadingMessage.chat.id, loadingMessage.message_id, '✏ You have no referrals yet.', { + reply_markup: shareMyReferralLinkMenu, + }); + + return; + } + + let refString = 'πŸ’° My Referrals πŸ’°\n\n'; + + userReferrals.forEach((refId) => { + refString += `Referral id: ${refId}\n`; + }); + + if (referralTrades.length === 0) { + await ctx.api.editMessageText(loadingMessage.chat.id, loadingMessage.message_id, refString, { + reply_markup: shareMyReferralLinkMenu, + parse_mode: 'HTML', + }); + + return; + } + + const feesEarnedSummary = referralTrades.reduce((map, tradeData) => { + const { feeAmount, feeCoinType } = tradeData; + const coinType = isSuiCoinType(feeCoinType) ? LONG_SUI_COIN_TYPE : feeCoinType; + const coinAmount = map.get(coinType); + + const newAmount = coinAmount !== undefined ? new BigNumber(coinAmount).plus(feeAmount).toString() : feeAmount; + map.set(coinType, newAmount); + + return map; + }, new Map()); + + if (userReferrals.length === 0) { + refString = '✏ You have no referrals now.\n'; + } + + refString += '\nπŸ’΅ Fees earned:\n'; + + const coinManager = await getCoinManager(); + let someAmountIsRaw = false; + + for (const [coinType, coinAmount] of feesEarnedSummary) { + const coinData = await coinManager.getCoinByType2(coinType); + + if (coinData === null) { + refString += `${coinType}: ${coinAmount} (*raw)\n\n`; + someAmountIsRaw = true; + + continue; + } + + const { decimals, symbol } = coinData; + const formattedAmount = new BigNumber(coinAmount).div(10 ** decimals).toString(); + const coinSymbol = symbol?.toUpperCase() ?? coinType; + + refString += `${coinSymbol}: ${formattedAmount}\n`; + } + + if (someAmountIsRaw) { + refString += "*raw β€” amount doesn't respect decimals."; + } + + await ctx.api.editMessageText(loadingMessage.chat.id, loadingMessage.message_id, refString, { + reply_markup: shareMyReferralLinkMenu, + parse_mode: 'HTML', + }); +} diff --git a/src/chains/referral/pages/qr-code.ts b/src/chains/referral/pages/qr-code.ts new file mode 100644 index 0000000..bd48bb3 --- /dev/null +++ b/src/chains/referral/pages/qr-code.ts @@ -0,0 +1,17 @@ +import shareMyReferralLinkMenu from '../../../menu/referral/share-link'; +import { BotContext } from '../../../types'; +import { getReferralLink, getReferralQrCode } from '../utils'; + +export async function showReferralQrCodePage(ctx: BotContext) { + const loadingMessage = await ctx.reply('Loading...', { parse_mode: 'HTML' }); + const referralLink = getReferralLink(ctx.session.referral.referralId); + const qrCode = await getReferralQrCode(referralLink); + + await ctx.api.deleteMessage(loadingMessage.chat.id, loadingMessage.message_id); + + await ctx.replyWithPhoto(qrCode, { + caption: `βœ‰ Your referral QR-code\n\nJust show it to your friend and enjoy 😎`, + reply_markup: shareMyReferralLinkMenu, + parse_mode: 'HTML', + }); +} diff --git a/src/chains/referral/pages/referrer.ts b/src/chains/referral/pages/referrer.ts new file mode 100644 index 0000000..a11011d --- /dev/null +++ b/src/chains/referral/pages/referrer.ts @@ -0,0 +1,16 @@ +import referrerMenu from '../../../menu/referral/referrer'; +import { BotContext } from '../../../types'; + +export async function showReferrerPage(ctx: BotContext) { + const referrerId = ctx.session.referral.referrer.id; + + let text = '✏ Referrer is a person whose referral link or QR-code you followed.\n\n'; + + if (referrerId === null) { + text += 'βœ– You have no referrer.'; + } else { + text += `Your referrer id: ${referrerId}`; + } + + await ctx.reply(text, { reply_markup: referrerMenu, parse_mode: 'HTML' }); +} diff --git a/src/chains/referral/redis/type-guards.ts b/src/chains/referral/redis/type-guards.ts new file mode 100644 index 0000000..0c8f64c --- /dev/null +++ b/src/chains/referral/redis/type-guards.ts @@ -0,0 +1,12 @@ +import { ReferralTradeData } from '../types'; + +export function isReferralTradeData(data: unknown): data is ReferralTradeData { + return ( + typeof data === 'object' && + data !== null && + 'feeCoinType' in data && + typeof data.feeCoinType === 'string' && + 'feeAmount' in data && + typeof data.feeAmount === 'string' + ); +} diff --git a/src/chains/referral/redis/utils.ts b/src/chains/referral/redis/utils.ts new file mode 100644 index 0000000..f20fa5f --- /dev/null +++ b/src/chains/referral/redis/utils.ts @@ -0,0 +1,228 @@ +import { RedisStorageClient, SwapFee } from '@avernikoz/rinbot-sui-sdk'; +import { getRedisClient } from '../../../config/redis.config'; +import { ReferralRedisKeyFirstPart, ReferrerFieldKey } from '../config'; +import { ReferralTradeData } from '../types'; +import { isReferralTradeData } from './type-guards'; + +export async function storeReferral({ + referralId, + publicKey, +}: { + referralId: string; + publicKey: string; +}): Promise { + const { redisClient } = await getRedisClient(); + + await storeReferrerField({ redisClient, publicKey, referralId }); +} + +export async function storeReferrerField({ + referralId, + publicKey, + redisClient, +}: { + referralId: string; + publicKey: string; + redisClient: RedisStorageClient; +}) { + const key = `${ReferralRedisKeyFirstPart.Referrer}:${referralId}`; + + const keyAlreadyExists = !!(await redisClient.exists(key)); + + if (keyAlreadyExists) { + console.error( + `[storeReferrerField] Cannot store referral with "${referralId}" id and "${publicKey}" public key, ` + + `because referrer with such a key already exists.`, + ); + + return; + } + + const fields = { + [ReferrerFieldKey.PublicKey]: publicKey, + }; + const fieldsCount = Object.keys(fields).length; + + const countOfCreatedFields = await redisClient.hSet(key, fields); + + if (countOfCreatedFields !== fieldsCount) { + console.warn( + `[storeReferrerField] Count of created fields is not equal to ${fieldsCount} while creating referral with ` + + `"${referralId}" id and "${publicKey}" public key.`, + ); + } +} + +export async function getReferrerPublicKey(referrerId: string): Promise { + const key = `${ReferralRedisKeyFirstPart.Referrer}:${referrerId}`; + const { redisClient } = await getRedisClient(); + + const publicKey = await redisClient.hGet(key, ReferrerFieldKey.PublicKey); + + if (publicKey === undefined) { + console.warn(`[getReferrerPublicKey] Cannot get public key of referrer with id ${referrerId}`); + + return null; + } + + return publicKey; +} + +export async function addReferralToReferrer({ + referralId, + referrerId, +}: { + referralId: string; + referrerId: string; +}): Promise { + const { redisClient } = await getRedisClient(); + + const key = `${ReferralRedisKeyFirstPart.Referrals}:${referrerId}`; + const referralJoinedTime = Date.now(); + + const fields = { + [referralId]: referralJoinedTime, + }; + const fieldsCount = Object.keys(fields).length; + + const countOfCreatedFields = await redisClient.hSet(key, fields); + + if (countOfCreatedFields !== fieldsCount) { + console.warn( + `[addReferralToReferrer] Count of created fields is not equal to ${fieldsCount} while adding referral ` + + `with "${referralId}" id to referrer with "${referrerId}" id`, + ); + } +} + +export async function removeReferralFromReferrer({ + referralId, + referrerId, +}: { + referralId: string; + referrerId: string; +}) { + const { redisClient } = await getRedisClient(); + + const key = `${ReferralRedisKeyFirstPart.Referrals}:${referrerId}`; + + const deletedFieldsCount = await redisClient.hDel(key, referralId); + const countOfFieldsMustBeDeleted = 1; + + if (deletedFieldsCount !== countOfFieldsMustBeDeleted) { + console.warn( + `[removeReferralFromReferrer] Count of deleted fields is not equal to ${countOfFieldsMustBeDeleted} ` + + `for removing referral with "${referralId}" id for referrer with "${referrerId}" id.`, + ); + } +} + +export async function getReferrerKeys(): Promise { + const { redisClient } = await getRedisClient(); + const referrerKeys = await redisClient.keys(`${ReferralRedisKeyFirstPart.Referrer}:*`); + + return referrerKeys; +} + +export async function getExistingReferralIds(): Promise { + const referrerKeys = await getReferrerKeys(); + + const firstPartWithColon = `${ReferralRedisKeyFirstPart.Referrer}:`; + + const referralIds = referrerKeys.reduce((ids: string[], referrerKey: string) => { + const idStartIndex = referrerKey.indexOf(firstPartWithColon) + firstPartWithColon.length; + + if (idStartIndex !== -1 && idStartIndex < referrerKey.length) { + const id = referrerKey.substring(idStartIndex); + ids.push(id); + } + + return ids; + }, []); + + return referralIds; +} + +export async function getUserReferrals(userReferralId: string): Promise { + const key = `${ReferralRedisKeyFirstPart.Referrals}:${userReferralId}`; + const { redisClient } = await getRedisClient(); + + const referralRecords = await redisClient.hGetAll(key); + const referralIds = Object.keys(referralRecords); + + return referralIds; +} + +export async function addReferralTrade({ + referrerId, + referrerPublicKey, + fees, + digest, + feeCoinType, +}: { + referrerId: string; + referrerPublicKey: string; + fees: SwapFee['fees']; + digest: string; + feeCoinType: string; +}) { + const key = `${ReferralRedisKeyFirstPart.ReferralsTrades}:${referrerId}`; + const { redisClient } = await getRedisClient(); + + const referrerFeeObject = fees.find((feeObj) => feeObj.feeCollectorAddress === referrerPublicKey); + + if (referrerFeeObject === undefined) { + console.warn( + `[addReferralTrade] Cannot find a fee object with feeCollectorAddress === "${referrerPublicKey}" ` + + `for digest "${digest}", referrerId "${referrerId}" and fees "${JSON.stringify(fees)}"`, + ); + + return; + } + + const feeAmount = referrerFeeObject.feeAmount; + + const fields = { + [digest]: JSON.stringify({ feeCoinType, feeAmount }), + }; + const fieldsCount = Object.keys(fields).length; + + const createdFieldsCount = await redisClient.hSet(key, fields); + + if (createdFieldsCount !== fieldsCount) { + console.warn( + `[addReferralTrade] Count of created fields is not equal to ${fieldsCount} while adding referral trade ` + + `with "${digest}" digest to referrer with "${referrerId}" id`, + ); + } +} + +export async function getReferralsTrades(referrerId: string): Promise { + const key = `${ReferralRedisKeyFirstPart.ReferralsTrades}:${referrerId}`; + const { redisClient } = await getRedisClient(); + + const data = await redisClient.hGetAll(key); + const referralsTradesStringifiedData = Object.values(data); + + const referralsTradesDataRaw: (ReferralTradeData | null)[] = referralsTradesStringifiedData.map((strData: string) => { + try { + const tradeData = JSON.parse(strData); + + if (isReferralTradeData(tradeData)) { + return tradeData; + } else { + console.warn(`[getReferralsTrades] Trade data "${JSON.stringify(tradeData)}" is not the ReferralTradeData.`); + return null; + } + } catch (error) { + console.error(`[getReferralsTrades] Cannot JSON.parse the following strData: ${strData}. Error occured:`, error); + return null; + } + }); + + const referralsTradesData: ReferralTradeData[] = referralsTradesDataRaw.filter( + (tradeData): tradeData is ReferralTradeData => tradeData !== null, + ); + + return referralsTradesData; +} diff --git a/src/chains/referral/types.ts b/src/chains/referral/types.ts new file mode 100644 index 0000000..2dd4e23 --- /dev/null +++ b/src/chains/referral/types.ts @@ -0,0 +1 @@ +export type ReferralTradeData = { feeCoinType: string; feeAmount: string }; diff --git a/src/chains/referral/utils.ts b/src/chains/referral/utils.ts new file mode 100644 index 0000000..2ded8ee --- /dev/null +++ b/src/chains/referral/utils.ts @@ -0,0 +1,54 @@ +import { QRCodeCanvas } from '@loskir/styled-qr-code-node'; +import { InputFile } from 'grammy'; +import { BotContext, MyConversation } from '../../types'; +import { RINBOT_LOGO_URL, RINBOT_URL } from '../sui.config'; +import { imageUrlToBase64 } from '../utils'; +import { PARAMS_SEPARATOR, REFERRAL_QR_CODE_CONFIG, REF_PARAM_KEY } from './config'; +import { getReferrerPublicKey } from './redis/utils'; + +export function generateReferralId(): string { + return Date.now().toString(); +} + +export function getReferralLink(referralId: string): string { + return `${RINBOT_URL}?start=${REF_PARAM_KEY}=${referralId}`; +} + +export async function getReferralQrCode(referralLink: string): Promise { + const rinbotLogoBase64 = await imageUrlToBase64(RINBOT_LOGO_URL); + const qrCode = new QRCodeCanvas({ ...REFERRAL_QR_CODE_CONFIG, data: referralLink, image: rinbotLogoBase64 }); + + const buffer = await qrCode.toBuffer(); + const file = new InputFile(buffer); + + return file; +} + +export function getReferrerIdByParams(params: string) { + const separatorIndex = params.indexOf(PARAMS_SEPARATOR); + + const keyWithEqualSign = `${REF_PARAM_KEY}=`; + const refStartIndex = params.indexOf(keyWithEqualSign); + const referralIdIndex = refStartIndex + keyWithEqualSign.length; + + if (separatorIndex === -1) { + return params.substring(referralIdIndex); + } else { + return params.substring(referralIdIndex, separatorIndex); + } +} + +export async function setReferrerPublicKey({ + ctx, + conversation, + referrerId, +}: { + ctx: BotContext; + conversation?: MyConversation; + referrerId: string; +}): Promise { + const referrerPublicKey = await getReferrerPublicKey(referrerId); + const session = conversation ? conversation.session : ctx.session; + + session.referral.referrer.publicKey = referrerPublicKey; +} diff --git a/src/chains/sui.config.ts b/src/chains/sui.config.ts index 3211a99..86e955b 100644 --- a/src/chains/sui.config.ts +++ b/src/chains/sui.config.ts @@ -12,6 +12,10 @@ export const COIN_WHITELIST_URL = 'https://rinbot-dev.vercel.app/api/coin-whitel export const RINBOT_CHAT_URL = 'https://t.me/rinbot_chat'; +export const RINBOT_URL = 'https://t.me/RINsui_bot'; + +export const RINBOT_LOGO_URL = 'https://i.ibb.co/BNQHQPt/rinbot-logo-2-edge-blur-min.png'; + export const SELL_DELAY_AFTER_BUY_FOR_CLAIMERS_IN_MS = 4 * 60 * 60 * 1000; // 4 hours export const SUI_COIN_DATA = { symbol: 'SUI', decimals: SUI_DECIMALS, type: LONG_SUI_COIN_TYPE }; diff --git a/src/chains/trading/buy/buy.ts b/src/chains/trading/buy/buy.ts index 1493945..68b999e 100644 --- a/src/chains/trading/buy/buy.ts +++ b/src/chains/trading/buy/buy.ts @@ -1,15 +1,15 @@ import { FeeManager, LONG_SUI_COIN_TYPE, SUI_DECIMALS } from '@avernikoz/rinbot-sui-sdk'; -import { EXTERNAL_WALLET_ADDRESS_TO_STORE_FEES } from '../../../config/bot.config'; import { retryAndGoHomeButtonsData } from '../../../inline-keyboards/retryConversationButtonsFactory'; import { BotContext, MyConversation } from '../../../types'; import { CallbackQueryData } from '../../../types/callback-queries-data'; import { ConversationId } from '../../conversations.config'; import { signAndExecuteTransactionWithoutConversation } from '../../conversations.utils'; import { getUserFeePercentage } from '../../fees/utils'; +import { addReferralTrade } from '../../referral/redis/utils'; import { TransactionResultStatus, randomUuid } from '../../sui.functions'; import { getSuiVisionTransactionLink, reactOnUnexpectedBehaviour, userMustUseCoinWhitelist } from '../../utils'; import { SwapSide } from '../types'; -import { getBestRouteTransactionDataWithExternal, printSwapInfo } from '../utils'; +import { getBestRouteTransactionDataWithExternal, getSwapFees, printSwapInfo } from '../utils'; import { askForAmountToSpendOnBuy, askForCoinToBuy } from './utils'; export async function buy(conversation: MyConversation, ctx: BotContext) { @@ -44,6 +44,7 @@ export async function buy(conversation: MyConversation, ctx: BotContext) { */ export const instantBuy = async (conversation: MyConversation, ctx: BotContext) => { const retryButton = retryAndGoHomeButtonsData[ConversationId.InstantBuy]; + const { id: referrerId, publicKey: referrerPublicKey } = conversation.session.referral.referrer; await ctx.reply('Finding the best route to save your money… ☺️' + randomUuid); @@ -53,13 +54,18 @@ export const instantBuy = async (conversation: MyConversation, ctx: BotContext) settings: { swapWithConfirmation, slippagePercentage }, } = conversation.session; - const feePercentage = (await getUserFeePercentage(ctx)).toString(); - const feeAmount = FeeManager.calculateFeeAmountIn({ + const feePercentage = await conversation.external(async () => (await getUserFeePercentage(ctx)).toString()); + + const generalFeeAmount = FeeManager.calculateFeeAmountIn({ feePercentage, amount: tradeAmountPercentage, tokenDecimals: SUI_DECIMALS, }); + const fees = await conversation.external(() => + getSwapFees({ generalFeeAmount, referrerId, referrerPublicKey, conversation }), + ); + const data = await getBestRouteTransactionDataWithExternal({ conversation, ctx, @@ -70,8 +76,8 @@ export const instantBuy = async (conversation: MyConversation, ctx: BotContext) signerAddress: publicKey, slippagePercentage: slippagePercentage, fee: { - feeAmount, - feeCollectorAddress: EXTERNAL_WALLET_ADDRESS_TO_STORE_FEES, + tokenFromDecimals: SUI_DECIMALS, + fees, }, }, }); @@ -131,6 +137,18 @@ export const instantBuy = async (conversation: MyConversation, ctx: BotContext) }; } + const { id: referrerId, publicKey: referrerPublicKey } = conversation.session.referral.referrer; + + if (referrerId !== null && referrerPublicKey !== null) { + addReferralTrade({ + referrerId, + referrerPublicKey, + digest: resultOfSwap.digest, + feeCoinType: LONG_SUI_COIN_TYPE, + fees, + }); + } + return; } diff --git a/src/chains/trading/config.ts b/src/chains/trading/config.ts index 93bd910..ac27d05 100644 --- a/src/chains/trading/config.ts +++ b/src/chains/trading/config.ts @@ -1 +1,3 @@ export const DEFAULT_PRICE_DIFFERENCE_THRESHOLD_PERCENTAGE = 5; + +export const REFERRER_FEE_SHARE_PERCENTAGE = 50; diff --git a/src/chains/trading/sell.ts b/src/chains/trading/sell.ts index 0390fc6..090477f 100644 --- a/src/chains/trading/sell.ts +++ b/src/chains/trading/sell.ts @@ -1,7 +1,9 @@ import { CoinAssetData, CoinManagerSingleton, + FeeManager, LONG_SUI_COIN_TYPE, + SwapFee, isSuiCoinType, isValidTokenAddress, } from '@avernikoz/rinbot-sui-sdk'; @@ -13,6 +15,7 @@ import { BotContext, MyConversation } from '../../types'; import { CallbackQueryData } from '../../types/callback-queries-data'; import { ConversationId } from '../conversations.config'; import { signAndExecuteTransaction } from '../conversations.utils'; +import { getUserFeePercentage } from '../fees/utils'; import { RINCEL_COIN_TYPE } from '../sui.config'; import { getCoinManager, getWalletManager, randomUuid } from '../sui.functions'; import { @@ -26,7 +29,8 @@ import { reactOnUnexpectedBehaviour, } from '../utils'; import { SwapSide } from './types'; -import { getBestRouteTransactionDataWithExternal, printSwapInfo } from './utils'; +import { getBestRouteTransactionDataWithExternal, getSwapFees, printSwapInfo } from './utils'; +import { addReferralTrade } from '../referral/redis/utils'; async function askForCoinToSell({ ctx, @@ -295,6 +299,51 @@ export async function sell(conversation: MyConversation, ctx: BotContext): Promi const amountToSell = new BigNumber(validCoinToSell.balance).multipliedBy(absolutePercentage).toString(); + const { id: referrerId, publicKey: referrerPublicKey } = conversation.session.referral.referrer; + const feePercentage = await conversation.external(async () => (await getUserFeePercentage(ctx)).toString()); + + let fee: SwapFee | undefined = undefined; + let coinDecimals: number | undefined; + + // We need the input coin decimals to calculate `generalFeeAmount` + if (validCoinToSell.decimals !== null) { + coinDecimals = validCoinToSell.decimals; + } else { + const fetchedCoinDecimals = await conversation.external(async () => { + const coinManager = await getCoinManager(); + return (await coinManager.getCoinByType2(validCoinToSell.type))?.decimals; + }); + + coinDecimals = fetchedCoinDecimals; + } + + // Charging fees only when the input coin decimals are defined + if (coinDecimals !== undefined) { + const generalFeeAmount = FeeManager.calculateFeeAmountIn({ + feePercentage, + amount: amountToSell, + tokenDecimals: coinDecimals, + }); + + const fees = await conversation.external(() => + getSwapFees({ generalFeeAmount, referrerId, referrerPublicKey, conversation }), + ); + + const inputCoinObjects = await conversation.external(async () => { + const wallet = await getWalletManager(); + return await wallet.getAllCoinObjects({ + publicKey: conversation.session.publicKey, + coinType: validCoinToSell.type, + }); + }); + + fee = { + fees, + tokenFromDecimals: coinDecimals, + tokenFromCoinObjects: inputCoinObjects, + }; + } + await ctx.reply('Finding the best route to save your money… ☺️'); const data = await getBestRouteTransactionDataWithExternal({ @@ -306,6 +355,7 @@ export async function sell(conversation: MyConversation, ctx: BotContext): Promi amount: amountToSell, signerAddress: conversation.session.publicKey, slippagePercentage: conversation.session.settings.slippagePercentage, + fee, }, }); @@ -359,6 +409,18 @@ export async function sell(conversation: MyConversation, ctx: BotContext): Promi conversation.session.tradesCount = conversation.session.tradesCount + 1; + const { id: referrerId, publicKey: referrerPublicKey } = conversation.session.referral.referrer; + + if (referrerId !== null && referrerPublicKey !== null && fee !== undefined) { + addReferralTrade({ + referrerId, + referrerPublicKey, + digest: resultOfSwap.digest, + feeCoinType: validCoinToSell.type, + fees: fee.fees, + }); + } + return; } diff --git a/src/chains/trading/utils.ts b/src/chains/trading/utils.ts index aa793c3..98b14af 100644 --- a/src/chains/trading/utils.ts +++ b/src/chains/trading/utils.ts @@ -1,16 +1,20 @@ import { LONG_SUI_COIN_TYPE, + NonEmptyArray, RouteManager, SHORT_SUI_COIN_TYPE, isSuiCoinType, transactionFromSerializedTransaction, } from '@avernikoz/rinbot-sui-sdk'; import BigNumber from 'bignumber.js'; +import { EXTERNAL_WALLET_ADDRESS_TO_STORE_FEES } from '../../config/bot.config'; import confirmWithCloseKeyboard from '../../inline-keyboards/mixed/confirm-with-close'; import { BotContext, MyConversation } from '../../types'; +import { getReferrerPublicKey } from '../referral/redis/utils'; import { SUI_COIN_DATA } from '../sui.config'; import { getCoinManager, getRouteManager, randomUuid } from '../sui.functions'; import { getCoinPrice } from '../utils'; +import { REFERRER_FEE_SHARE_PERCENTAGE } from './config'; import { SwapSide } from './types'; export async function printSwapInfo({ @@ -235,3 +239,45 @@ export function getActionString({ side, userShouldConfirmSwap }: { side: SwapSid return actionPartString; } + +export async function getSwapFees({ + referrerId, + referrerPublicKey, + generalFeeAmount, + conversation, +}: { + referrerId: string | null; + referrerPublicKey: string | null; + generalFeeAmount: string; + conversation?: MyConversation; +}): Promise> { + let fees: NonEmptyArray<{ feeAmount: string; feeCollectorAddress: string }>; + + // Edge case scenario, when something went wrong while setting the referrer public key + if (referrerId !== null && referrerPublicKey === null) { + referrerPublicKey = await getReferrerPublicKey(referrerId); + + if (conversation !== undefined) { + conversation.session.referral.referrer.publicKey = referrerPublicKey; + } + } + + if (referrerId !== null && referrerPublicKey !== null) { + const referrerFeeAmount = new BigNumber(generalFeeAmount) + .multipliedBy(REFERRER_FEE_SHARE_PERCENTAGE) + .dividedBy(100) + .toFixed(0); + + const aldrinFeePercentage = new BigNumber(100).minus(REFERRER_FEE_SHARE_PERCENTAGE); + const aldrinFeeAmount = new BigNumber(generalFeeAmount).multipliedBy(aldrinFeePercentage).dividedBy(100).toFixed(0); + + fees = [ + { feeAmount: referrerFeeAmount, feeCollectorAddress: referrerPublicKey }, + { feeAmount: aldrinFeeAmount, feeCollectorAddress: EXTERNAL_WALLET_ADDRESS_TO_STORE_FEES }, + ]; + } else { + fees = [{ feeAmount: generalFeeAmount, feeCollectorAddress: EXTERNAL_WALLET_ADDRESS_TO_STORE_FEES }]; + } + + return fees; +} diff --git a/src/chains/types.ts b/src/chains/types.ts index 2c63400..367c039 100644 --- a/src/chains/types.ts +++ b/src/chains/types.ts @@ -13,3 +13,6 @@ export type CoinWhitelistItem = { symbol: string; type: string; }; + +export type CoinType = string; +export type CoinAmount = string; diff --git a/src/index.ts b/src/index.ts index 54540be..ef36fc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,10 @@ import { ConversationId } from './chains/conversations.config'; import { buySurfdogTickets } from './chains/launchpad/surfdog/conversations/conversations'; import { SurfdogConversationId } from './chains/launchpad/surfdog/conversations/conversations.config'; import { showSurfdogPage } from './chains/launchpad/surfdog/show-pages/showSurfdogPage'; +import { changeReferrer } from './chains/referral/conversations/change-referrer'; +import { handleReferralFollow } from './chains/referral/handle-referral-follow'; +import { storeReferral } from './chains/referral/redis/utils'; +import { generateReferralId, getReferrerIdByParams } from './chains/referral/utils'; import { checkProvidedAddress } from './chains/refunds/conversations/checkProvidedAddress'; import { DEFAULT_SLIPPAGE } from './chains/settings/slippage/percentages'; import { createAftermathPool, createCoin, generateWallet, home, withdraw } from './chains/sui.functions'; @@ -26,6 +30,7 @@ import { timeoutMiddleware } from './middleware/timeoutMiddleware'; import { addBoostedRefund } from './migrations/addBoostedRefund'; import { addIndexToAssets } from './migrations/addIndexToAssets'; import { addPriceDifferenceThreshold } from './migrations/addPriceDifferenceThreshold'; +import { addReferralField } from './migrations/addReferralField'; import { addRefundFields } from './migrations/addRefundFields'; import { addSuiAssetField } from './migrations/addSuiAssetField'; import { addSwapConfirmationSetting } from './migrations/addSwapConfirmationSetting'; @@ -62,6 +67,7 @@ async function startBot(): Promise { initial: (): SessionData => { const { privateKey, publicKey } = generateWallet(); const boostedRefundAccount = generateWallet(); + const referralId = generateReferralId(); documentClient .put({ @@ -87,6 +93,8 @@ async function startBot(): Promise { }) .catch((e) => console.error('ERROR storing boosted account', e)); + storeReferral({ referralId, publicKey }).catch((e) => console.error('ERROR storing referral:', e)); + return { privateKey, publicKey, @@ -126,6 +134,7 @@ async function startBot(): Promise { boostedRefundAccount, }, trades: {}, + referral: { referralId, referrer: { id: null, publicKey: null, newId: null } }, }; }, storage: enhanceStorage({ @@ -144,6 +153,7 @@ async function startBot(): Promise { 11: addSwapConfirmationSetting, 12: addPriceDifferenceThreshold, 13: addIndexToAssets, + 14: addReferralField, }, }), }); @@ -201,6 +211,7 @@ async function startBot(): Promise { }), ); protectedConversationsComposer.use(createConversation(instantBuy, { id: ConversationId.InstantBuy })); + protectedConversationsComposer.use(createConversation(changeReferrer, { id: ConversationId.ChangeReferrer })); bot.errorBoundary(generalErrorBoundaryHandler).use(conversationsComposer); @@ -262,6 +273,18 @@ async function startBot(): Promise { }); bot.command('start', async (ctx) => { + const params = ctx.match; + const referrerId = getReferrerIdByParams(params); + + if (referrerId !== '') { + const { referrerIsChanging } = await handleReferralFollow({ ctx, referrerId }); + + // If `ChangeReferrer` conversation started, we do not need `home()` to be called + if (referrerIsChanging) { + return; + } + } + await home(ctx); }); diff --git a/src/inline-keyboards/settings/referral/back.ts b/src/inline-keyboards/settings/referral/back.ts new file mode 100644 index 0000000..8777150 --- /dev/null +++ b/src/inline-keyboards/settings/referral/back.ts @@ -0,0 +1,6 @@ +import { InlineKeyboard } from 'grammy'; +import { CallbackQueryData } from '../../../types/callback-queries-data'; + +const backToMainReferralMenu = new InlineKeyboard().text('Back', CallbackQueryData.BackToMainReferralMenu); + +export default backToMainReferralMenu; diff --git a/src/menu/referral/referral.ts b/src/menu/referral/referral.ts new file mode 100644 index 0000000..a0853e2 --- /dev/null +++ b/src/menu/referral/referral.ts @@ -0,0 +1,26 @@ +import { Menu } from '@grammyjs/menu'; +import { showMyReferralsPage } from '../../chains/referral/pages/my-referrals'; +import { showReferrerPage } from '../../chains/referral/pages/referrer'; +import { home } from '../../chains/sui.functions'; +import { BotContext } from '../../types'; +import referrerMenu from './referrer'; +import shareMyReferralMenu, { shareMyReferralMenuId } from './share'; +import shareMyReferralLinkMenu from './share-link'; + +const referralMenu = new Menu('referral') + .submenu('Share', shareMyReferralMenuId) + .row() + .text('My Referrals', async (ctx) => { + await showMyReferralsPage(ctx); + }) + .text('Referrer', async (ctx) => { + await showReferrerPage(ctx); + }) + .row() + .text('Home', async (ctx) => { + await home(ctx); + }); + +referralMenu.register([shareMyReferralMenu, shareMyReferralLinkMenu, referrerMenu]); + +export default referralMenu; diff --git a/src/menu/referral/referrer.ts b/src/menu/referral/referrer.ts new file mode 100644 index 0000000..f688f2e --- /dev/null +++ b/src/menu/referral/referrer.ts @@ -0,0 +1,22 @@ +import { Menu } from '@grammyjs/menu'; +import { showMainReferralPage } from '../../chains/referral/pages/main'; +import { BotContext } from '../../types'; +import { ConversationId } from '../../chains/conversations.config'; + +const referrerMenu = new Menu('referrer') + .text( + (ctx) => { + const userHasNoReferrer = ctx.session.referral.referrer.id === null; + return userHasNoReferrer ? 'Set Referrer' : 'Change Referrer'; + }, + async (ctx) => { + ctx.session.referral.referrer.newId = null; + await ctx.conversation.enter(ConversationId.ChangeReferrer); + }, + ) + .row() + .text('Back', async (ctx) => { + await showMainReferralPage(ctx); + }); + +export default referrerMenu; diff --git a/src/menu/referral/share-link.ts b/src/menu/referral/share-link.ts new file mode 100644 index 0000000..f7ce398 --- /dev/null +++ b/src/menu/referral/share-link.ts @@ -0,0 +1,9 @@ +import { Menu } from '@grammyjs/menu'; +import { BotContext } from '../../types'; +import { showMainReferralPage } from '../../chains/referral/pages/main'; + +const shareMyReferralLinkMenu = new Menu('share-referral-link').text('Back', async (ctx) => { + await showMainReferralPage(ctx); +}); + +export default shareMyReferralLinkMenu; diff --git a/src/menu/referral/share.ts b/src/menu/referral/share.ts new file mode 100644 index 0000000..b9a24dd --- /dev/null +++ b/src/menu/referral/share.ts @@ -0,0 +1,18 @@ +import { Menu } from '@grammyjs/menu'; +import { showReferralLinkPage } from '../../chains/referral/pages/link'; +import { showReferralQrCodePage } from '../../chains/referral/pages/qr-code'; +import { BotContext } from '../../types'; + +export const shareMyReferralMenuId = 'share-referral'; + +const shareMyReferralMenu = new Menu(shareMyReferralMenuId) + .text('QR-code', async (ctx) => { + await showReferralQrCodePage(ctx); + }) + .text('Link', async (ctx) => { + await showReferralLinkPage(ctx); + }) + .row() + .back('Back'); + +export default shareMyReferralMenu; diff --git a/src/menu/settings.ts b/src/menu/settings.ts index a057e1e..4db02cf 100644 --- a/src/menu/settings.ts +++ b/src/menu/settings.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import { Menu } from '@grammyjs/menu'; import { showCoinWhitelist } from '../chains/coin-whitelist/showCoinWhitelist'; import { showFeesPage } from '../chains/fees/showFeesPage'; @@ -7,8 +8,9 @@ import { userMustUseCoinWhitelist } from '../chains/utils'; import { userAgreement } from '../home/user-agreement'; import goHome from '../inline-keyboards/goHome'; import { BotContext } from '../types'; -// eslint-disable-next-line max-len +import { showMainReferralPage } from '../chains/referral/pages/main'; import { showPriceDifferenceThresholdPage } from '../chains/settings/price-difference-threshold/show-price-difference-page'; +import referralMenu from './referral/referral'; const settingsMenu = new Menu('settings') .text('Slippage', async (ctx) => { @@ -36,6 +38,11 @@ const settingsMenu = new Menu('settings') await ctx.reply(userAgreement, { parse_mode: 'HTML', reply_markup: goHome }); }) .row() + .text('Referral', async (ctx) => { + await showMainReferralPage(ctx); + }) .back('Home'); +settingsMenu.register(referralMenu); + export default settingsMenu; diff --git a/src/middleware/callbackQueries.ts b/src/middleware/callbackQueries.ts index bbb8bbf..7ca6e43 100644 --- a/src/middleware/callbackQueries.ts +++ b/src/middleware/callbackQueries.ts @@ -1,12 +1,13 @@ +/* eslint-disable max-len */ import { Bot } from 'grammy'; import { showCoinWhitelist } from '../chains/coin-whitelist/showCoinWhitelist'; import { ConversationId } from '../chains/conversations.config'; import { SurfdogConversationId } from '../chains/launchpad/surfdog/conversations/conversations.config'; import { showSurfdogPage } from '../chains/launchpad/surfdog/show-pages/showSurfdogPage'; import { showUserTickets } from '../chains/launchpad/surfdog/show-pages/showUserTickets'; +import { showMainReferralPage } from '../chains/referral/pages/main'; import { showRefundsPage } from '../chains/refunds/showRefundsPage'; import { priceDifferenceThresholdPercentages } from '../chains/settings/price-difference-threshold/percentages'; -// eslint-disable-next-line max-len import { showPriceDifferenceThresholdPage } from '../chains/settings/price-difference-threshold/show-price-difference-page'; import { slippagePercentages } from '../chains/settings/slippage/percentages'; import { showSlippageConfiguration } from '../chains/settings/slippage/showSlippageConfiguration'; @@ -42,6 +43,7 @@ export function useCallbackQueries(bot: Bot) { useCoinWhitelistCallbackQueries(bot); useSwapConfirmationCallbackQueries(bot); usePriceDifferenceThresholdCallbackQueries(bot); + useReferralCallbackQueries(bot); Object.keys(retryAndGoHomeButtonsData).forEach((conversationId) => { bot.callbackQuery(`retry-${conversationId}`, async (ctx) => { @@ -144,3 +146,10 @@ function useSwapConfirmationCallbackQueries(bot: Bot) { await showSwapConfirmationPage(ctx); }); } + +function useReferralCallbackQueries(bot: Bot) { + bot.callbackQuery(CallbackQueryData.BackToMainReferralMenu, async (ctx) => { + await ctx.answerCallbackQuery(); + await showMainReferralPage(ctx); + }); +} diff --git a/src/migrations/addReferralField.ts b/src/migrations/addReferralField.ts new file mode 100644 index 0000000..3fdbf3a --- /dev/null +++ b/src/migrations/addReferralField.ts @@ -0,0 +1,14 @@ +import { storeReferral } from '../chains/referral/redis/utils'; +import { generateReferralId } from '../chains/referral/utils'; +import { SessionData } from '../types'; + +export function addReferralField(old: SessionData) { + const referralId = generateReferralId(); + + storeReferral({ publicKey: old.publicKey, referralId }); + + return { + ...old, + referral: { referralId, referrer: { id: null, publicKey: null, newId: null } }, + }; +} diff --git a/src/types/callback-queries-data.ts b/src/types/callback-queries-data.ts index d590855..714bc0b 100644 --- a/src/types/callback-queries-data.ts +++ b/src/types/callback-queries-data.ts @@ -14,4 +14,5 @@ export enum CallbackQueryData { Home = 'go-home', DisableSwapConfirmation = 'disable-swap-confirmation', EnableSwapConfirmation = 'enable-swap-confirmation', + BackToMainReferralMenu = 'back-to-main-referral-menu', } diff --git a/src/types/index.ts b/src/types/index.ts index 58b6c29..13a78e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -88,6 +88,7 @@ export interface SessionData { } | null; }; trades: { [coinType: string]: { lastTradeTimestamp: number } }; + referral: { referralId: string; referrer: { id: string | null; publicKey: string | null; newId: string | null } }; } export type BotContext = Context & SessionFlavor & ConversationFlavor; diff --git a/yarn.lock b/yarn.lock index e08025e..d8a612b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -865,7 +865,15 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@mapbox/node-pre-gyp@^1.0.5": +"@loskir/styled-qr-code-node@^1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@loskir/styled-qr-code-node/-/styled-qr-code-node-1.5.1.tgz#975757ffcfd69bba705d37e6fcd5bad69380e46d" + integrity sha512-GAGaUiQw+6b27nVJWtURUMXbqEr5mk9OOHoMfqhbGKKWI8fEcICf5VFkxcMgI/kQrSgOh/yag+aBb3rKA11gwg== + dependencies: + qrcode-generator "^1.4.4" + skia-canvas "^1.0.0" + +"@mapbox/node-pre-gyp@^1.0.5", "@mapbox/node-pre-gyp@^1.0.9": version "1.0.11" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== @@ -2529,6 +2537,11 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +cargo-cp-artifact@^0.1: + version "0.1.8" + resolved "https://registry.yarnpkg.com/cargo-cp-artifact/-/cargo-cp-artifact-0.1.8.tgz#353814f49f6aa76601a4bcb3ea5f3071180b90de" + integrity sha512-3j4DaoTrsCD1MRkTF2Soacii0Nx7UHCce0EwUf4fHnggwiE4fbmF2AbnfzayR36DF8KGadfh7M/Yfy625kgPlA== + chalk@^4.0.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -2763,6 +2776,13 @@ decimal.js@^10.4.1, decimal.js@^10.4.3: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -3635,7 +3655,7 @@ glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^8.0.1: +glob@^8.0.1, glob@^8.0.3: version "8.1.0" resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== @@ -4476,6 +4496,11 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + minimatch@9.0.3, minimatch@^9.0.0, minimatch@^9.0.1, minimatch@^9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" @@ -4992,7 +5017,7 @@ obliterator@^1.6.1: resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-1.6.1.tgz#dea03e8ab821f6c4d96a299e17aef6a3af994ef3" integrity sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig== -once@^1.3.0: +once@^1.3.0, once@^1.3.1: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== @@ -5063,6 +5088,11 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parenthesis@^3.1.5: + version "3.1.8" + resolved "https://registry.yarnpkg.com/parenthesis/-/parenthesis-3.1.8.tgz#3457fccb8f05db27572b841dad9d2630b912f125" + integrity sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw== + parse-conflict-json@^3.0.0, parse-conflict-json@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz#67dc55312781e62aa2ddb91452c7606d1969960c" @@ -5222,6 +5252,11 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qrcode-generator@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/qrcode-generator/-/qrcode-generator-1.4.4.tgz#63f771224854759329a99048806a53ed278740e7" + integrity sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw== + qrcode-terminal@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" @@ -5486,6 +5521,20 @@ sigstore@^1.3.0, sigstore@^1.4.0, sigstore@^1.9.0: "@sigstore/tuf" "^1.0.3" make-fetch-happen "^11.0.1" +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-update-notifier@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" @@ -5493,6 +5542,18 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" +skia-canvas@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/skia-canvas/-/skia-canvas-1.0.1.tgz#d0f6a4c99f273bb79311afa6e4410e09d5bb61b5" + integrity sha512-/HtZlmib2BD3Fi6P4ZywSq7VquPl2A1Frc0pA+xD5hJSvSzN7FoOJpBWDpDd0e1ByzrVO6ZIjO98dNSQ2XqlKw== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.9" + cargo-cp-artifact "^0.1" + glob "^8.0.3" + path-browserify "^1.0.1" + simple-get "^4.0.1" + string-split-by "^1.0.0" + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -5593,6 +5654,13 @@ strict-event-emitter-types@^2.0.0: resolved "https://registry.yarnpkg.com/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz#05e15549cb4da1694478a53543e4e2f4abcf277f" integrity sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA== +string-split-by@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/string-split-by/-/string-split-by-1.0.0.tgz#53895fb3397ebc60adab1f1e3a131f5372586812" + integrity sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A== + dependencies: + parenthesis "^3.1.5" + "string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"