Skip to main content

Notification Settings in BTC Tracker Channel

This example is intended to get you understand slider based notification settings with a real-world application. For the example we are going to look at a scenario where users can choose a time interval 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 slider type notification settings implementation where you as a user can specify the time interval and/or required percentage change of a token on which he/she would like to get notified.

We will choose the BTC Tracker channel to demonstrate this example.

Creating BTC 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 btcTickerKeys.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 btcTickerSettings.json file in the channel folder​

Use the below code for the settings file.

{
"cmcEndpoint": "https://pro-api.coinmarketcap.com/",
"providerUrl":"SEPOLIA_PROVIDER_HERE",
"route":"v1/cryptocurrency/quotes/latest",
"cmcKey":"CMC_API_KEY_HERE",
"id": 1
}

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

The btcTickerChannel.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.web3MainnetNetwork;

@Service()
export default class BtcTickerChannel 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: 'BTC Tracker',
url: 'https://push.org/',
useOffChain: true,
});
}
}

What's going on here?

  • We are creating a new class BtcTickerChannel 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:

  • Fetch current prices of tokens using the CoinMarketCap API
// 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);
  • Initialize userAlice for the channel using your private key and signer.
// Initalize provider, signer and userAlice for Channel interaction
const provider = new ethers.providers.JsonRpcProvider(settings.providerUrl);
const signer = new ethers.Wallet(keys.PRIVATE_KEY_NEW_STANDARD.PK, provider);
const userAlice = await PushAPI.initialize(signer, {
env: CONSTANTS.ENV.STAGING,
});
  • Fetch the current prices, hourly, daily and weekly change for notification payload.
// Get the required prices here
const price = data.BTC.quote.USD.price;
const formattedPrice = Number(Number(price).toFixed(2));

const hourChange = Number(data.BTC.quote.USD.percent_change_1h);
const dayChange = Number(data.BTC.quote.USD.percent_change_24h);
const weekChange = Number(data.BTC.quote.USD.percent_change_7d);

const hourChangeFixed = hourChange.toFixed(2);
const dayChangeFixed = dayChange.toFixed(2);
const weekChangeFixed = weekChange.toFixed(2);
  • Before we begin with the logic, we need to fetch the get the current cycles and previous BTC price from our database.
// Retrive Global data
const btcTrackerGlobalData =
(await btcTickerGlobalModel.findOne({ _id: 'btcTrackerGlobal' })) ||
(await btcTickerGlobalModel.create({
_id: 'btcTrackerGlobal',
prevBtcPrice: Number(formattedPrice),
cycles: 0,
}));

// Assign cycles and prevBtcPrice
const CYCLES = btcTrackerGlobalData.cycles ? btcTrackerGlobalData.cycles : 0;
const prevPrice = btcTrackerGlobalData.prevBtcPrice
? btcTrackerGlobalData.prevBtcPrice
: 0;

// Update current price as prev price
await btcTickerGlobalModel.findByIdAndUpdate(
{ _id: 'btcTrackerGlobal' },
{ prevBtcPrice: Number(formattedPrice) },
{ upsert: true }
);

// Calculate percentage change
const globalChangePercentage = Math.round(
(Math.abs(formattedPrice - prevPrice) / prevPrice) * 100
);
  • Build a payload using the above details
// Build Payload Content
let changeInper = Number(
((Math.abs(formattedPrice - prevPrice) / prevPrice) * 100).toFixed(2)
);

const title = 'BTC at $' + formattedPrice;
const message = `\nHourly Movement: ${hourChangeFixed}%\nDaily Movement: ${dayChangeFixed}%\nWeekly Movement: ${weekChangeFixed}%`;
const payloadTitle = `BTC Price Movement`;
const globalPayloadMsg = `BTC at [t:$${formattedPrice} (${
changeInper >= 0
? changeInper < 100
? `+` + changeInper + '%'
: '+' + 0 + '%'
: `-` + changeInper + '%'
})]\n\nHourly Movement: ${
hourChange >= 0
? '[s: +' + hourChangeFixed + '%]'
: '[d: -' + hourChangeFixed + '%]'
}\nDaily Movement: ${
dayChange >= 0
? '[s: +' + dayChangeFixed + '%]'
: '[d: -' + dayChangeFixed + '%]'
}\nWeekly Movement: ${
weekChange >= 0
? '[s: +' + weekChangeFixed + '%]'
: '[d: -' + weekChangeFixed + '%]'
}[timestamp: ${Math.floor(Date.now() / 1000)}]`;

Here, you can use colours as per your wish. For example price pump is shown in blue and price dump is shown in red/pink colour in this case.

  • Fetch the current subscribers of the channel using subscribers() in the Push SDK
// Looping for subscribers' data in the channel
while (true) {
const userData: any = await userAlice.channel.subscribers({
page: i,
limit: 30,
setting: true,
});
if (userData.itemcount != 0) {
i++;
} else {
i = 1;
// UPDATE CYCLES VALUE
// HERE
await btcTickerGlobalModel.findOneAndUpdate(
{ _id: 'btcTrackerGlobal' },
{ $inc: { cycles: 3 } },
{ upsert: true },
);

break;
}

// Next block of code goes here
}
  • Loop across each subscriber to fetch their userSettings

Here, we need to track the price of BTC at which a user got notified and send a notification accordingly. Therefore, we need to build custom notifications for the user. Here, is how we can do it:

await Promise.all(
userData.subscribers.map(async (subscriberObj: { settings: string; subscriber: any }) => {

// Converting String to JS object
const userSettings = JSON.parse(subscriberObj.settings);

// Fetch users last btc price & last cycle values
const userDBValue =
(await btcTickerUserModel.findOne({ _id: subscriberObj.subscriber })) ||
(await btcTickerUserModel.create({
_id: subscriberObj.subscriber,
lastCycle: btcTrackerGlobalData.cycles,
lastbtcPrice: btcTrackerGlobalData.prevBtcPrice,
}));

// Calculation of percentage change for each subscriber
const changePercentage = (
(Math.abs(formattedPrice - Number(userDBValue.lastBtcPrice) || prevPrice) /
Number(userDBValue.lastBtcPrice) || prevPrice) * 100
).toFixed(2);

// Build payload message for each subscriber
let payloadMsg;

if (Number(changePercentage) == 0) {
payloadMsg = `BTC at [t:$${formattedPrice} ( 0 %
)]\n\nHourly Movement: ${
hourChange >= 0 ? '[s: +' + hourChangeFixed + '%]' : '[d: ' + hourChangeFixed + '%]'
}\nDaily Movement: ${
dayChange >= 0 ? '[s: +' + dayChangeFixed + '%]' : '[d: ' + dayChangeFixed + '%]'
}\nWeekly Movement: ${
weekChange >= 0 ? '[s: +' + weekChangeFixed + '%]' : '[d: ' + weekChangeFixed + '%]'
}[timestamp: ${Math.floor(Date.now() / 1000)}]`;
} else {
let changeInpercentage = Number(
(
((formattedPrice - Number(userDBValue.lastBtcPrice) || prevPrice) /
Number(userDBValue.lastBtcPrice) || prevPrice) * 100
).toFixed(2),
);
payloadMsg = `BTC at [t:$${formattedPrice} (${
changeInpercentage > 0
? changeInpercentage < 100
? `+` + changeInpercentage + '%'
: '+' + 0 + '%'
: `-` + changeInpercentage + '%'
})]\n\nHourly Movement: ${
hourChange >= 0 ? '[s: +' + hourChangeFixed + '%]' : '[d: ' + hourChangeFixed + '%]'
}\nDaily Movement: ${
dayChange >= 0 ? '[s: +' + dayChangeFixed + '%]' : '[d: ' + dayChangeFixed + '%]'
}\nWeekly Movement: ${
weekChange >= 0 ? '[s: +' + weekChangeFixed + '%]' : '[d: ' + weekChangeFixed + '%]'
}[timestamp: ${Math.floor(Date.now() / 1000)}]`;
}

// Only perform computation if user settings exist
if (userSettings !== null) {
/*
{
Handle the notification trigger cases here
}
*/
} else {
//Send Notifications to old users
// Build Payload
await btcTickerUserModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES, lastBtcPrice: Number(formattedPrice) },
{ upsert: true },
);

const payload = {
type: 3, // Type of Notification
notifTitle: title, // Title of Notification
notifMsg: message, // Message of Notification
title: payloadTitle, // Internal Title
msg: payloadMsg, // Internal Message
recipient: subscriberObj.subscriber, // Recipient
};
// Send notification
this.sendNotification({
recipient: payload.recipient, // new
title: payload.notifTitle,
message: payload.notifMsg,
payloadTitle: payload.title,
payloadMsg: payload.msg,
notificationType: payload.type,
simulate: simulate,
image: null,
});
}
})
  • Now, we need to trigger notifications as per the users' channel settings combination. i) User opted for both time interval and percentage change, ii) Only percentage change and iii) Only Time interval
// if both Change percentage and Time interval is enabled
if (userSettings[0]?.enabled == true && userSettings[1]?.enabled == true) {
const settingUserValue1 = userSettings[0].user; // Percent Change
const settingUserValue2 =
userSettings[1].user == 0 ? 3 : userSettings[1].user; // Time interval

// Case for if user opts-in, opts-out and again opts-in later in time interval
const presentInDb = (await btcTickerUserModel.findOne({
_id: subscriberObj.subscriber,
}))
? true
: false;

if (presentInDb) {
const userDBValueCheck = await btcTickerUserModel.findOne({
_id: subscriberObj.subscriber,
});

if (
Number(userDBValueCheck.lastCycle + settingUserValue2) < Number(CYCLES)
) {
// Set current cycle as lastCycle
await btcTickerUserModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES },
{ upsert: true }
);
}
}

// --------------------------------------------------------------------------------

// Check if user changed their settings
const userDBValueBefore =
(await btcTickerUserModel.findOne({ _id: subscriberObj.subscriber })) ||
(await btcTickerUserModel.create({
_id: subscriberObj.subscriber,
lastCycle: CYCLES,
settingsValue: settingUserValue2,
}));

const userSettingsDBValue = userDBValueBefore.settingsValue
? userDBValueBefore.settingsValue
: 0;
const userChangedValue = userSettingsDBValue != settingUserValue2; // true

if (userChangedValue) {
await btcTickerUserModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES, settingsValue: settingUserValue2 }
);
}

// ------------------------------------------------------------------------

const userDBValue = await btcTickerUserModel.findOne({
_id: subscriberObj.subscriber,
});

if (userDBValue.lastCycle + settingUserValue2 == CYCLES) {
if (changePercentage >= settingUserValue1) {
// UPDATE the users mapped value in DB
await btcTickerUserModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES, lastBtcPrice: Number(formattedPrice) },
{ upsert: true }
);

// Sending Notification
try {
// Build Payload
const payload = {
type: 3, // Type of Notification
notifTitle: title, // Title of Notification
notifMsg: message, // Message of Notification
title: payloadTitle, // Internal Title
msg: payloadMsg, // Internal Message
recipient: subscriberObj.subscriber, // Recipient
};

// Send notification
this.sendNotification({
recipient: payload.recipient, // new
title: payload.notifTitle,
message: payload.notifMsg,
payloadTitle: payload.title,
payloadMsg: payload.msg,
notificationType: payload.type,
simulate: simulate,
image: null,
});
} catch (error) {
this.logError(`Error sending notification: ${error}`);
}
} else {
// UPDATE the users mapped value in DB
await btcTickerUserModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES, lastBtcPrice: Number(formattedPrice) },
{ upsert: true }
);
}
}
}
// if only Change percentage is enabled
else if (userSettings[0]?.enabled === true) {
const settingUserValue1 = userSettings[0].user; // Percent Change

if (Math.abs(Number(globalChangePercentage)) >= settingUserValue1) {
// Sending Notification
try {
// Build Payload
const payload = {
type: 3, // Type of Notification
notifTitle: title, // Title of Notification
notifMsg: message, // Message of Notification
title: payloadTitle, // Internal Title
msg: globalPayloadMsg, // Internal Message
recipient: subscriberObj.subscriber, // Recipient
};

// Send notification
this.sendNotification({
recipient: payload.recipient, // new
title: payload.notifTitle,
message: payload.notifMsg,
payloadTitle: payload.title,
payloadMsg: payload.msg,
notificationType: payload.type,
simulate: simulate,
image: null,
});
} catch (error) {
this.logError(`Error sending notification: ${error}`);
}
}
}
// if only Time interval is enabled
else if (userSettings[1]?.enabled === true) {
const settingUserValue2 =
userSettings[1].user == 0 ? 3 : userSettings[1].user; // Time interval

// Case for if user opts-in, opts-out and again opts-in later in time interval
const presentInDb = (await btcTickerUserModel.findOne({
_id: subscriberObj.subscriber,
}))
? true
: false;

if (presentInDb) {
const userDBValueCheck = await btcTickerUserModel.findOne({
_id: subscriberObj.subscriber,
});

if (
Number(userDBValueCheck.lastCycle + settingUserValue2) < Number(CYCLES)
) {
// Set current cycle as lastCycle
await btcTickerUserModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES },
{ upsert: true }
);
}
}

// --------------------------------------------------------------------------------

// Check if user changed their settings
const userDBValueBefore =
(await btcTickerUserModel.findOne({ _id: subscriberObj.subscriber })) ||
(await btcTickerUserModel.create({
_id: subscriberObj.subscriber,
lastCycle: CYCLES,
settingsValue: settingUserValue2,
}));

const userSettingsDBValue = userDBValueBefore.settingsValue
? userDBValueBefore.settingsValue
: 0;
const userChangedValue = userSettingsDBValue != settingUserValue2;

if (userChangedValue) {
await btcTickerUserModel.findOneAndUpdate(
{ _id: subscriberObj.subscriber },
{ lastCycle: CYCLES, settingsValue: settingUserValue2 }
);
}

// ------------------------------------------------------------------------

const userDBValue = await btcTickerUserModel.findOne({
_id: subscriberObj.subscriber,
});

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

// Sending Notification
try {
// Build Payload
const payload = {
type: 3, // Type of Notification
notifTitle: title, // Title of Notification
notifMsg: message, // Message of Notification
title: payloadTitle, // Internal Title
msg: payloadMsg, // Internal Message
recipient: subscriberObj.subscriber, // Recipient
};

// Send notification
this.sendNotification({
recipient: payload.recipient, // new
title: payload.notifTitle,
message: payload.notifMsg,
payloadTitle: payload.title,
payloadMsg: payload.msg,
notificationType: payload.type,
simulate: simulate,
image: null,
});
} catch (error) {
this.logError(`Error sending notification: ${error}`);
}
}
}

🀯Those were a lots of code out there. Let's understand what is actually happening there and what conditions 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:

if (userDBValue.lastCycle + settingUserValue2 == CYCLES) {
if (changePercentage >= settingUserValue1) {
}
}

We just fetched the prices from the CMC API and using the previous price stored in database as per user, 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 which cycle did he opted in. 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πŸ˜‰.

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. The only difference here is we use the globalChangePercentage instead of the users last price.

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.

This wraps up the channel logic. Now, let's move onto buidling the cron-jobs file and model file.

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

import { model, Schema } from 'mongoose';

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

const btcTickerUserSchema = new Schema<BtcTickerUserData>({
_id: {
type: String,
},
lastCycle: {
type: Number,
},
lastBtcPrice: {
type: Number
},
settingsValue: {
type: Number,
}
});

export const btcTickerUserModel = model<BtcTickerUserData>('btcTickerUserDB', btcTickerUserSchema);

export interface BtcTickerGlobal {
_id?: string;
prevBtcPrice?: number;
cycles?: number;
}

const btcTickerGlobalSchema = new Schema<BtcTickerGlobal>({
_id: {
type: String,
},
prevBtcPrice: {
type: Number,
},
cycles: {
type: Number,
},
});

export const btcTickerGlobalModel = model<BtcTickerGlobal>('btcTickerGlobalDB', btcTickerGlobalSchema);

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

Step 9: Create a btcTickerJobs.ts file in the folder.​

// Do Scheduling
// https://github.com/node-schedule/node-schedule
// * * * * * *
// ┬ ┬ ┬ ┬ ┬ ┬
// β”‚ β”‚ β”‚ β”‚ β”‚ β”‚
// β”‚ β”‚ β”‚ β”‚ β”‚ β”” day of week (0 - 7) (0 or 7 is Sun)
// β”‚ β”‚ β”‚ β”‚ └───── month (1 - 12)
// β”‚ β”‚ β”‚ └────────── day of month (1 - 31)
// β”‚ β”‚ └─────────────── hour (0 - 23)
// β”‚ └──────────────────── minute (0 - 59)
// └───────────────────────── second (0 - 59, OPTIONAL)
// Execute a cron job every 5 Minutes = */5 * * * *
// Starts from seconds = * * * * * *

import logger from '../../loaders/logger';

import { Container } from 'typedi';
import schedule from 'node-schedule';

import BtcTickerChannel from './btcTickerChannel';

export default async () => {
const startTime = new Date(new Date().setHours(0, 0, 0, 0));

const threeHourRule = new schedule.RecurrenceRule();
threeHourRule.hour = new schedule.Range(0, 23, 3);
threeHourRule.minute = 0;
threeHourRule.second = 0;

const channel = Container.get(BtcTickerChannel);
channel.logInfo(`πŸ›΅ Scheduling Showrunner`);

schedule.scheduleJob(
{ start: startTime, rule: threeHourRule },
async function () {
const taskName = 'BTC Ticker Fetch and sendMessageToContract()';
try {
await channel.sendMessageToContract(true);
logger.info(
`[${new Date(Date.now())}] 🐣 Cron Task Completed -- ${taskName}`
);
} catch (err) {
logger.error(
`[${new Date(Date.now())}] ❌ Cron Task Failed -- ${taskName}`
);
logger.error(`[${new Date(Date.now())}] Error Object: %o`, err);
}
}
);
};

You can change the scheduling frequency as per your use-case.

Wrapping it UP πŸš€β€‹

Congratulations🎊...you have just built a amazing channel that tracks and notifies you about BTC price without you worrying about missing on important price movements and always be in the game. Isn't it cool?

Channel settings just opened a whole new notification experience window for users just like you and me. Now, you have all the divine knowledge about the channel settings. So, put your thinking caps on and built some cool stuff with it.

See you until the next time. Keep Building🎊