Skip to main content

Price Tracker in Showrunners

This example is intended to get you understand channel Settings with a real-world application. For the example we are going to look at a scenario where users can choose a their preferred tokens, a time interval and a price percentage change and showrunners framework will notify them as per their request. Checkout Showrunners Docs, Showrunners Framework, Channel Settings Docs and Channel Settings Demo for better understanding!

What we gonna build?​

Imagine you are a crypto trader or a general crypto enthusiast. You want to be notified every once in a while about the price movements and activities in the market. But you either lose track of time or forget about it. To solve this exact problem, we will be looking into a Price Tracker and Channel Settings implementation where you as a user can specify conditions on which you would like to get notified.

Creating Price Tracker in Showrunners​

Step 1: Setup the Showrunners in your local machine​

For detailed, step-by-step guide visit the Showrunners docs. First we need to create a folder in src/showrunners/<your_channel_name>

Step 2: Install Dependencies & start up​

Navigate to the SDK directory and install required dependencies.

cd push-showrunners-framework
yarn install
docker-compose up
yarn run dev

Step 3: Import the Push SDK​

After you have created a channel folder. Refer to Showrunners docs. Move to the [name]Channel.ts file and import the dependencies.

import { PushAPI } from '@pushprotocol/restapi';

Step 4: Create a priceTrackerKeys.json file in the channel folder​

Use the boilerplate for the keys file. .

{
"PRIVATE_KEY_NEW_STANDARD": {
"PK": "0x{PRIVATE_KEY_HERE}",
"CHAIN_ID": "eip155:11155111"
},
"PRIVATE_KEY_OLD_STANDARD": "0x{PRIVATE_KEY_HERE}"
}

Step 5: Create a priceTrackerSettings.json file in the channel folder​

Use the below code for the settings file.

{
"cmcEndpoint": "https://pro-api.coinmarketcap.com/",
"providerUrl":"https://ethereum-sepolia.publicnode.com",
"route":"v2/cryptocurrency/quotes/latest",
"cmcKey":"CMC_AP_KEY_HERE",
"id":"1,1027,...,3890,9111", // IDs as per CMC API
"tokenNames":["BTC","ETH",...,"MATIC","PUSH"] // Token names as per CMC API
}

Step 6: Create a priceTrackerChannel.ts file in the channel folder​

The preiceTrackerChannel.ts will be the file which will contain all the logic for the fetching the data and constructing the payload.

There is some boilerplate code involved in creating a channel. The channel.ts file will contain the following boilerplate:

import { Inject, Service } from 'typedi';
import { Logger } from 'winston';
import config, { defaultSdkSettings } from '../../config';
import { EPNSChannel } from '../../helpers/epnschannel';

const NETWORK_TO_MONITOR = config.web3SepoliaTestnetNetwork;

// Author : Shubham Patel(aeyshuhb)

@Service()
export default class PricetrackerChannel extends EPNSChannel {
constructor(@Inject('logger') public logger: Logger, @Inject('cached') public cached) {
super(logger, {
sdkSettings: {
epnsCoreSettings: defaultSdkSettings.epnsCoreSettings,
epnsCommunicatorSettings: defaultSdkSettings.epnsCommunicatorSettings,
networkSettings: defaultSdkSettings.networkSettings,
},
networkToMonitor: NETWORK_TO_MONITOR,
dirname: __dirname,
name: 'Price Tracker',
url: 'https://app.push.org/',
useOffChain: true,
});
}
}

What's going on here?

  • We are creating a new class PricetrackerChannel which extends the Push Channel class.
  • In the super() the constructor we pass in certain arguments required for the channel like the networkToMonitor , name, and URL for the channel.
  • The useOffChain the parameter tells the showrunner to use the off-chain notification instead of an on-chain one.

Step 7: Getting started with the channel logic​

Our objective is to create a channel to send notifications about price movements depending upon users' settings (Time interval and Percentage change here). So, to achieve this we will follow the following logic:

  • Create a function triggerUserNotification which will be called every 3 hours by the Cron job we are setting in Jobs file.
  public async triggerUserNotification(simulate) {
const logger = this.logger;

try {
this.logInfo(`🔔🔔Sending notifications`);

// Get New price function call
await this.getNewPrice(simulate);
} catch (error) {
logger.error(`[${new Date(Date.now())}]-[Price Tracker]- Errored on CMC API... skipped with error: %o`, err);
}
}```

- Call getNewPrice and fetch current prices of tokens using the CoinMarketCap API and store it in array.
- Initialize `userAlice` for the channel using your private key and signer.

```js
public async getNewPrice(simulate) {
try {
const logger = this.logger;
logger.debug(`[${new Date(Date.now())}]-[Pricetracker]-Getting price of tokens... `);

// API URL components and settings
const cmcroute = settings.route;
const cmcEndpoint = settings.cmcEndpoint;
const pollURL = `${cmcEndpoint}${cmcroute}?id=${settings.id}&aux=cmc_rank&CMC_PRO_API_KEY=${
settings.cmcKey || config.cmcAPIKey
}`;

// Fetching data from the CMC API
let { data } = await axios.get(pollURL);
data = data.data;

// Initalize provider, signer and userAlice for Channel interaction
const provider = new ethers.providers.JsonRpcProvider(config.web3TestnetSepoliaProvider || settings.providerUrl);
const signer = new ethers.Wallet(keys.PRIVATE_KEY_NEW_STANDARD.PK, provider);
const userAlice = await PushAPI.initialize(signer, { env: CONSTANTS.ENV.STAGING });

// Global variables
let i = 1;
let tokenInfo = [];

// Structuring token data info
for (let id in data) {
let tokenPrice = data[id].quote.USD?.price;
let tokenSymbol = data[id].symbol;
let formattedPrice = Number(Number(tokenPrice).toFixed(2));
tokenInfo.push({ symbol: tokenSymbol, price: formattedPrice });
}
}catch(e){console.log("Error occured"+e)}
}
  • first,we are creating a variable to store Cycles value in db so that we can track user's time interval settings and to decide on which cron job to send notification.
  • up next, we will fetch the current subscribers of the channel using subscribers(),this method will return us an array of 10 elemnts in which each element will have a address and user's channel settings for that address which will denote what are the fields a user is interested in/opted in.
  • further we are incrementing the user's cycle value by 3 for every cron job they have executed/taken part in,this will helpe us to track when to send notification to user and in which cycle.
 // Global variables from DB
const priceTrackerGlobalData =
(await priceTrackerGlobalModel.findOne({ _id: 'global' })) ||
(await priceTrackerGlobalModel.create({
_id: 'global',
cycles: 0,
}));

// Set CYCLES variable in DB
const CYCLES = priceTrackerGlobalData.cycles;

// Looping for subscribers' data in the channel
while (true) {
const userData: any = await userAlice.channel.subscribers({
page: i,
limit: 10,
setting: true,
});

if (userData.itemcount != 0) {
i++;
} else {
i = 1;

// UPDATE CYCLES VALUE
// HERE
await priceTrackerGlobalModel.findOneAndUpdate({ _id: 'global' }, { $inc: { cycles: 3 } }, { upsert: true });
const priceTickerGlobalData = await priceTrackerGlobalModel.findOne({ _id: 'global' });

// this.logInfo(`Cycles value after all computation: ${priceTickerGlobalData?.cycles}`);

break;
}
}
  • Loop across each subscriber to fetch their userSettings
await Promise.all(
userData?.subscribers?.map(async (subscriberObj: { settings: string; subscriber: any }) => {
// Converting String to JS object
let userSettings = JSON.parse(subscriberObj?.settings);

// For merging different token details in payload
const notifData2 = [];

// Only perform computation if user settings exist
try {
if (userSettings !== null) {
this.logInfo(`Subs ${subscriberObj.subscriber}`);
// Looping through userSettings to handle each userSetting
await Promise.all(
userSettings?.map(async (mapObj, index) => {
// Your code logic goes here
})
);
}
} catch (error) {
// Handle error here
}
})
);
  • Loop through every userSetting (Tokens) user selected.
if (mapObj.user == true) {
// Get current price of the token
const currentToken = tokenInfo.find(
(obj) => obj.symbol === mapObj.description
);
const currentPrice = currentToken?.price;
}
  • Calculate changePercentage using prevPrice stored in database and update it.
// Get current price of the token
const currentToken = tokenInfo.find((obj) => obj.symbol === mapObj.description);
const currentPrice = currentToken.price;

// Get previous token price
const previousPriceData = (await priceTrackerTokenModel.findOne({
_id: mapObj.description,
}))
? await priceTrackerTokenModel.findOne({ _id: mapObj.description })
: 0;

// Update the new price
await priceTrackerTokenModel.findOneAndUpdate(
{ _id: mapObj.description },
{ tokenPrevPrice: currentPrice },
{ upsert: true }
);

// Calculate Change
const changePercentage = (
(Math.abs(Number(currentPrice) - previousPriceData.tokenPrevPrice) /
previousPriceData.tokenPrevPrice) *
100
).toFixed(2);

/* Conditions go here */
  • This code block is responsible for handling price tracking notifications based on user settings.
  • Craft the 4 major conditions :

i. User opted for both time interval and percentage change

  • It checks if the price alert and time interval slider are enabled for a subscriber.
  • If both are enabled, it fetches the user values for settings and the user's last cycle values from the database.
  • It then compares the change percentage with the user-defined price value and checks if the last cycle value matches the current cycle.
  • If the conditions are met, it updates the user's mapped value in the database and builds a payload for the notification.
  • The payload includes the percentage change, description, and current price.

ii. Only percentage change

  • If only the price alert slider is enabled, it fetches the user value for the price setting and compares it with the change percentage.

iii. Only Time interval

  • If only the time interval slider is enabled, it fetches the user value for the time setting and the user's last cycle values from the database.
  • It then checks if the sum of the last cycle value and the user value matches the current cycle.
  • If the condition is met, it updates the user's mapped value in the database and builds a payload for the notification.

iv. Receive general notifications

  • If none of the sliders are enabled, it builds a payload for the notification without any conditions.
// The 4 conditions here
// index - 9 ---> Time Interval
// index - 10 ---> Price Change

if (userSettings[9]?.enabled == true && userSettings[10]?.enabled == true) {
this.logInfo(
`Price Alert & Time Interval Slider case: ${subscriberObj.subscriber}`
);

// Fetch user values for settings
let userValueTime = userSettings[9].user == 0 ? 3 : userSettings[9].user;
let userValuePrice = userSettings[10].user;

// Fetch user last cycle values
const userDBValue =
(await priceTrackerModel.findOne({ _id: subscriberObj.subscriber })) ||
(await priceTrackerModel.create({
_id: subscriberObj.subscriber,
lastCycle: priceTrackerGlobalData.cycles,
}));

this.logInfo(
`Mapped value of ${userDBValue._id} is ${userDBValue.lastCycle} from both price and time`
);
this.logInfo(
`User value of ${userDBValue._id} is ${userValueTime} from both price and time`
);

// Condition to trigger notification
if (
Math.abs(Number(changePercentage)) >= userValuePrice &&
userDBValue.lastCycle + userValueTime == CYCLES
) {
// UPDATE the users mapped value in DB
await priceTrackerModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES },
{ upsert: true }
);
// Build the payload of the notification
const payloadMsg =
Number(changePercentage) > 0
? `Percentage Change(${mapObj.description}): [s: +${Math.abs(Number(changePercentage))}% ($${currentPrice})]\n`
: `Percentage Change(${mapObj.description}): [d: -${Math.abs(Number(changePercentage))}% ($${currentPrice})]\n`;
this.logInfo(`Address: ${subscriberObj.subscriber} Data: ${payloadMsg}`);

notifData2.push({
key: `${Math.abs(Number(changePercentage))}`,
notif: `${payloadMsg}`,
});
}
} else if (userSettings[10]?.enabled == true) {
this.logInfo(`Price Alert Slider only case: ${subscriberObj.subscriber}`);

// Fetch user values for settings
let userValue = userSettings[10].user;

// Condition to trigger notification
if (Math.abs(Number(changePercentage)) >= userValue) {
// Build the payload of the notification
const payloadMsg =
Number(changePercentage) > 0
? `Percentage Change(${mapObj.description}): [s: +${Math.abs(Number(changePercentage))}% ($${currentPrice})]\n`
: `Percentage Change(${mapObj.description}): [d: -${Math.abs(Number(changePercentage))}% ($${currentPrice})]\n`;

notifData2.push({
key: `${Math.abs(Number(changePercentage))}`,
notif: `${payloadMsg}`,
});
}
} else if (userSettings[9]?.enabled == true) {
this.logInfo(`Time Interval Slider only case: ${subscriberObj.subscriber}`);

// Fetch user values for settings
let userValue = userSettings[9].user == 0 ? 3 : userSettings[9].user;

const userDBValue =
(await priceTrackerModel.findOne({ _id: subscriberObj.subscriber })) ||
(await priceTrackerModel.create({
_id: subscriberObj.subscriber,
lastCycle: priceTrackerGlobalData.cycles,
}));

if (userDBValue.lastCycle + userValue == CYCLES) {
// UPDATE the users mapped value in DB
await priceTrackerModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES },
{ upsert: true }
);

// Build the payload of the notification
const payloadMsg = `${mapObj.description} at[d:$${currentPrice}]\n`;

notifData2.push({ key: `${currentPrice}`, notif: `${payloadMsg}` });
}
} else {
// Build the payload of the notification
const payloadMsg = `${mapObj.description} at[d:$${currentPrice}]\n`;

notifData2.push({ key: `${currentPrice}`, notif: `${payloadMsg}` });
}

🤯Those were a lots of code out there. Let's understand what is actually happening there and what coditions trigger the notifications in different cases.

Case 1: Both percent change and time interval is enabled

  • When a user opts in to both these settings, what the user want is to receive a notification for their selected tokens when there is a particular change in price and it occured within the time interval. So, the basic logic here is:
// Condition to trigger notification
if (
Number(changePercentage) >= userValuePrice &&
userDBValue.lastCycle + userValueTime == CYCLES
) {
}

Also, there are 3 conditions that you need to lookout for:
i. What happens when time is triggered but not percentage?
ii. What happens if a user opts-in, opts-out and then again after several days opt-in?
iii. What happens if someone changes their time-interval settings?

We have already handled these edge cases in the code. Test yourself and see if you can find them😉. We just fetched the prices from the CMC API and using the previous price stored in database, we can calculate the changePercentage value. For the CYCLES variable, everytime our showrunners framework is executed it is incremented by 3 as the lowest ticker value in the slider is 3. You can change it as per your channel and logic. This helps us to calculate when a new user will receive a notification based on on ehich cycle did he opted in.

Case 2: Only percent change is enabled - Here, a user want to receive notification when there is a particular change in price. So, the basic logic here is:

// Condition to trigger notification
if (Number(changePercentage) >= userValue) {
}

The calculation for the changePercentage is same like Case 1.

Case 3: Only time interval is enabled - Here, a user want to receive notification as per their chosen interval. So, the basic logic here is:

// Condition to trigger notification
if (userDBValue.lastCycle + userValue == CYCLES) {
}

The calculation and significance of the CYCLES variable is explained in Case 1.

Case 4: Regular Notifications - Here, a user want to receive notification containing the price of their chosen token at regular intervals. So, we simple resolve this in a else condition.

Step 8: Create a priceTrackerModel.ts file in the folder.​

import { model, Schema } from 'mongoose';

export interface PriceTrackerData {
_id?: string;
lastCycle?: number;
settingsValue?: number;
}


const priceTrackerSchema = new Schema<PriceTrackerData>({
_id: {
type: String,
},
lastCycle: {
type: Number,
},
settingsValue: {
type: Number,
}
});

export const priceTrackerModel = model<PriceTrackerData>('priceTrackerUserDB', priceTrackerSchema);

export interface PriceTrackerGlobal {
_id?: string;
cycles?: number;
}

const priceTrackerGlobalSchema = new Schema<PriceTrackerGlobal>({
_id: {
type: String,
},
cycles: {
type: Number,
},
});

export const priceTrackerGlobalModel = model<PriceTrackerGlobal>('priceTrackerGlobalDB', priceTrackerGlobalSchema);

export interface PriceTrackerToken {
_id?: String;
symbol?: String;
tokenPrevPrice?: Number;
}

const PriceTrackerTokenSchema = new Schema<PriceTrackerToken>({
_id: String,
symbol: String,
tokenPrevPrice: Number,
});

export const priceTrackerTokenModel = model<PriceTrackerToken>('priceTokenTracker', PriceTrackerTokenSchema);

export interface UserTokenInfo {
_id?: String;
userTokenPrevPrice?: Number;
}

const UserTokenInfoSchema = new Schema<UserTokenInfo>({
_id: String,
userTokenPrevPrice: Number,
});

export const userTokenModel = model<UserTokenInfo>('userTokenInfo', UserTokenInfoSchema);

It is a good practise to write your Interface then Schema and then create your Model. Remember to keep different names of your database for each model.

Wrapping it UP 🚀​

Congratulations🎊...you have just built a amazing channel that let users subscribe and receive notifications of their favorite tokens. Now, they are not gonna miss a single update, isnt't it?

This is a very basic yet real-life use-case of channel settings paired up with the showrunners framework. The ways in which you can customize this to create basically any kind of notification is unlimited.

One can even go ahead and include an image in the notification using the image parameter in the sendNotification function.

That's all for this time. We'll see you in the next one and until then keep building amazing stuff👋