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"