Sending notifications from your Cartesi dApps with Push Protocol.
Enhancing the Rollups experience
Co-Author jjhbk
User experience is not only about a good user interface, it’s not only about the prettiest app and the arrangement of items on the user screen. It’s the whole journey of the user and how someone feels about their interactions. whatever is the service you provide. Always keeping the user engaged, happy, not allowing them to be frustrated by small things, all of that counts.
Even for classic applications a big part of the experience happens when the user is not looking. So why would a decentralized application (dApp) be any different? Some tools and protocols are now enabling developers to create more interactive, user-friendly experience that bridges the gap between code and real-world interactions.
One critical aspect of user experience even presented in one of Nielsen’s 10 heuristics is Visibility of System Status. Ensuring users are informed about important events is critical. And rollups have a very important need of tracking information, such as the epochs guaranteeing finality of a user action. Push Protocol provides an efficient way to deliver these notifications directly to users, enhancing the user experience and engagement.
In this guide, you will see how you can integrate Push Protocol with applications built with Cartesi Rollups to send notifications to users based on the dApp events. By the end of this tutorial, you’ll have a working dApp leveraging Push Protocol Notifications, ensuring users never miss out on critical updates.
Why Use Push Protocol with Cartesi Rollups?
As mentioned, Push Protocol allows you to send real-time notifications to users, which is a huge help in scenarios where timely action is required, such as executing vouchers — outputs that allow for the user to interact with other smart contracts — after an epoch is finalized. Cartesi Rollups offers off-chain computations and also provides the developer with so much flexibility to a point where devs have multiple ways of implementing solution.
Use Case Examples:
- Automated Asset Management: Notify users when their assets are ready for withdrawal after epoch finalization.
- Updates: Send instant notifications for dApp due activities, such as message received.
- Engagement: Keep users informed about game invites, or warn them it’s their turn in a turn based game.
Overview of the Solution
We could think of very complex techniques. Studying the framework to override the way outputs are generated. Understanding the intricacies of how the validator nodes works, forking the repository, building our own version of the node and creating a kind of output that interact with Push contracts to send information directly from the dApp logic. But sometimes, simple is more than enough, sometimes it is actually better.
In this guide, our approach is to create a simple cron job. This service will monitor Cartesi outputs, more specifically notices. It will observe them and their state, then send notifications to users via Push Protocol. It’s not a direct communication between the core of Cartesi and Push, but a simple and clean microservice that is a self contained solution.
Architecture:
The general idea is that the cron job will constantly look for the outputs generated by the dApp. Notices are listed through the graphQL API that the rollups framework provides and the content of these outputs will be sent to a Push Protocol channel that the developer has created. A user subscribed to a channel will receive notifications that the service created.
About the outputs
To understand the architecture presented above it is important to remember that there are three main types of outputs on Cartesi Rollups:
- Reports: Application logs or diagnostic information and cannot be validated on-chain.
- Notices: Informational statements that can be validated in the base layer blockchain by smart contract call that checks it’s proof attribute.
- Vouchers: Represents transactions that can be carried out on the base layer blockchain, such as asset transfers. They are used to effect changes in the base layer based on the application’s state.
Both notices and vouchers carry the attribute proof from the moment the epoch in which they were created closes. A notice can be confirmed to be correct via a contract call, by using this property. A voucher carries the proof until the moment it is executed, then calling whatever function it represents inside an external smart contract, and then this property ceases to exist.
When working with notices, there is a smaller chance of creating unexpected behavior as the proofs are always there. That’s why for this tutorial we decided to create two types of notifications.
- Instant Notification: If the user adds type: “instant” to the notice body, the notification is sent to subscribers immediately when the notice is generated. Very useful to tell players it’s their turn on a game.
- Proof Notification: If the user creates a proof notification the target only receives the notification when the proof is available. Very useful to warn users that the voucher they created to withdrawl assets from the application is ready to be executed.
If someone wants to know when the epoch from a voucher closes the only thing they have to do is create a Notice carrying the proof structure with the same epoch as that voucher, that means, in the same transaction.
For that we decided to have this model of output:
{
"__push_notification__": true, // Always has to be true, otherwise won't generate notification
"type": "instant", // "proof" for proofed notifications | "instant" for instantaneous ones
"message": "sample message", // content of the push notification
"target": "*", // * to broadcast to everyone subscribed to the channel | wallet address of receipient
}
Creating your push service
Step 1: Set Up the Push Notification Channel
First, you’ll need to create a Push Notification Channel to send notifications. Follow the Official Push Protocol Guide to set up your channel.
Step 2: Creating a job
Start a node.js project, that’s what we will use for this cron job:
npm init
Now that your project is set up, you can install required dependencies. You will install node-cron
and ethers
:
npm install node-cron ethers
To use modern notation for imports on js we have to add this line to your package json
{
...
"type": "module",
...
}
Create a new file named index.js
Add the following content:
import cron from 'node-cron';
import { ethers } from 'ethers';
import { pollNotices, pollNoticesWithProof } from './notice.poller.js';
import { sendPush } from './notification.sender.js';
function strToJson(payload) {
return JSON.parse(payload);
}
function jsonToStr(jsonString) {
return JSON.stringify(jsonString);
}
function hex2str(hex) {
return ethers.toUtf8String(hex);
}
function str2hex(str) {
return ethers.hexlify(ethers.toUtf8Bytes(str));
}
cron.schedule('*/3 * * * * *', async () => {
console.log('Running the cron job every 3 seconds')
let notices = await pollNotices()
for (let notice of notices) {
let body = strToJson(hex2str(notice.node.payload))
await sendPush(body.target, body.message)
}
let noticesWithProof = await pollNoticesWithProof()
for (let notice of noticesWithProof) {
let body = strToJson(hex2str(notice.node.payload))
await sendPush(body.target, "Proof emitted! " + body.message)
}
});
setInterval(() => {}, 1000);
cron.schedule('*/3 * * * * *', async () => {...})
: This schedules a task to run every 3 seconds.
The cron syntax */3 * * * * *
specifies this interval.
The task will:
- Fetch a list of notices without proof. These are events or messages that the Cartesi Rollups application has generated but are not yet verified with a proof.
- Loops through each notice retrieved.
- Converts the notice payload from hex to a string, and then parses it into a JSON object.
- Sends a push notification to the
target
with themessage
from the notice. - Does the same for notices with proof.
Now we create the notice.poller.js
This file is responsible for querying a GraphQL endpoint to retrieve notices from a Cartesi Rollups application.
import fetch from 'node-fetch';
import graphqlConfig from './graphql/config.js';
import { noticesQuery, noticeWCursor } from './graphql/queries.js';
let latestCursor = null;
let latestProofCursor = null;
latestCursor
andlatestProofCursor
: Variables are used to keep track of the latest cursor for the notices being fetched. A cursor is a pointer that marks a position in a list of records, allowing for efficient pagination when querying data.
async function pollNotices() {
try {
const variables = {
latestCursor: latestCursor
};const response = await fetch(graphqlConfig.endpoint, {
method: 'POST',
headers: graphqlConfig.headers,
body: JSON.stringify({ query: noticeWCursor, variables }),
});
if (response.ok) {
const data = await response.json();
const notices = data.data.notices.edges;
let noticeList = []
for (let notice of notices) {
if (notice.node.payload.includes("225f5f707573685f6e6f74696669636174696f6e5f5f223a74727565")
&& notice.node.payload.includes("2274797065223a22696e7374616e7422")) {
// Only add here if payload json contains "__push_notification__":true
// && Only add here if payload json contains "type":"instant"
noticeList.push(notice)
}
latestCursor = notice.cursor;
}
return noticeList
} else {
console.error("GraphQL Error:", data.errors);
}
} catch (error) {
console.error("Network Error:", error);
}
}
async function pollNoticesWithProof() {
try {
const variables = {
latestCursor: latestProofCursor
};
const response = await fetch(graphqlConfig.endpoint, {
method: 'POST',
headers: graphqlConfig.headers,
body: JSON.stringify({ query: noticeWCursor, variables }),
});
if (response.ok) {
const data = await response.json();
const notices = data.data.notices.edges;
let noticeList = []
for (let notice of notices) {
if (!notice.node.proof) {
break // In case there is no proof we spot and wait 30 more seconds
}
if (notice.node.payload.includes("225f5f707573685f6e6f74696669636174696f6e5f5f223a74727565")
&& notice.node.payload.includes("2274797065223a2270726f6f6622")) {
// Only add here if payload json contains "__push_notification__":true
// && Only add here if payload json contains "type":"proof"
noticeList.push(notice)
}
latestProofCursor = notice.cursor;
}
return noticeList
} else {
console.error("GraphQL Error:", data.errors);
}
} catch (error) {
console.error("Network Error:", error);
}
}
export {
pollNotices,
pollNoticesWithProof
}
These functions poll for new notices from the GraphQL endpoint and use the latestCursor
variables to ensure the query continues from the last position, preventing fetching duplicate data.
During the process it will filter notices that include specific strings within their payloads to make sure that they are push notifications through the __push_notification__":true
property and check the type of notification: “instant” or “proof”.
Now create a notification.sender.js
This file is responsible for sending push notifications through the Push Protocol API.
As you can see we are importing other packages here, so let’s make sure to install them
npm install @pushprotocol/restapi
npm install dotenv
Add the relevant imports
import { PushAPI, CONSTANTS } from '@pushprotocol/restapi';
import { ethers } from 'ethers';
import dotenv from "dotenv";
Do some setup by loading the environment variable containing the private key in the .env
file we are gonna create later
dotenv.config();
const PKEY = `0x${process.env.CHANNEL_PRIVATE_KEY}`;
const signer = new ethers.Wallet(PKEY);
And then add the logic.
const pushChannelAddress = "0x41070EfeD9Ead91380AAE5e164DAC1001F64C991";
const senderWallet = await PushAPI.initialize(signer, {
env: CONSTANTS.ENV.STAGING,
});
await senderWallet.notification.subscribe(
`eip155:11155111:${pushChannelAddress}` // channel address in CAIP format
);
async function sendPush(target, content) {
try {
const response = await senderWallet.channel.send([target], {
notification: {
title: "Cartesi DApp Notification",
body: content,
},
});
} catch (error) {
console.log(error);
}
}
export {
sendPush
}
Remember to replace the default channel address with your channel’s address:
pushChannelAddress = "0xYourChannelAddressHere";
And now we create a few supporting files to hold configs
.env
CHANNEL_PRIVATE_KEY = <Your private Key for the Push protocol channel>
graphql/config.js
const graphqlConfig = {
endpoint: 'http://localhost:8080/graphql',
headers: {
'Content-Type': 'application/json',
}
};
export default graphqlConfig;
graphql/queries.js
const noticesQuery = `
query notices {
notices {
edges {
node {
index
input {
index
}
payload
}
}
}
}
`
const noticeWCursor = `
query notices($latestCursor: String) {
notices(after: $latestCursor) {
edges {
node {
index
input {
index
timestamp
}
proof {
context
}
payload
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
export {
noticesQuery,
noticeWCursor
}
Run your cron job with the following command inside its folder:
node index.js
Step 3: Testing with a dApp!
To test the interaction with notices that your future Cartesi dApps will emit, create simple-node dApp by executing the following:
cartesi create simple-node --template javascript
cd simple-node
Open the Cartesi project created and add the following functions to its index.js
to create outputs easily.
function strToJson(payload) {
return JSON.parse(payload);
}
function jsonToStr(jsonString) {
return JSON.stringify(jsonString);
}
function hex2str(hex) {
return ethers.toUtf8String(hex);
}
function str2hex(str) {
return ethers.hexlify(ethers.toUtf8Bytes(str));
}
async function createNotice(decoded_payload, json = false) {
const advance_req = await postRequest("notice", decoded_payload, true)
const response = await advance_req.json();
console.log(
`Received notice status ${advance_req.status} with body `,
JSON.stringify(response)
);
return response;
}
async function createReport(decoded_payload, json=false) {
const report_req = await postRequest("report", decoded_payload, json)
console.log("Received report status " + report_req.status);
}
async function postRequest(endpoint, decoded_payload, json) {
let payload
if (json) {
payload = str2hex(jsonToStr(decoded_payload))
} else {
payload = str2hex(decoded_payload)
}
const req = await fetch(rollup_server + "/" + endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ payload }),
});
return req
}
And inside the handle_advance function of the same file, add the following logic:
async function handle_advance(data) {
console.log("Received advance request data " + JSON.stringify(data));
const payload = data["payload"];
const metadata = data["metadata"];
const sender = metadata["msg_sender"].toLowerCase();
const input = hex2str(payload)
let body
try {
body = strToJson(input)
} catch (error) {
await createReport("data sent is not json format")
return "reject"
}
if (body.message && body.target) {
body.__push_notification__ = true
}
await createNotice(body, true)
return "accept";
}
This logic simply gets the input data, adds the property __push_notification__ = true
to it and creates and output with the content.
After that you can build and run your Cartesi dApp.
cartesi build
cartesi run --epoch-length 30
The epoch-length flag means that the epoch will be closed in 30 seconds after the creation of the outputs.
Send test inputs in another terminal:
cartesi send generic
Example test inputs (string encoding):
{"type":"instant","message":"sample message","target":"*"}
{"type":"proof","message":"sample message","target":"*"} // This one should the 30 seconds (epoch time) to be created
Now you can check your inbox on Push Protocol dashboard and see that your notifications were sent!
Here is the repository if you just want the code ready to use:
https://github.com/Mugen-Builders/push-cartesi
Conclusion
Woof, that took a while? The important thing is that you integrated Push Protocol with Cartesi. With this integration, you can ensure that your users are always aware of critical actions they need to take, enhancing both user experience and engagement.
Please reach out in the Cartesi Community discord for help if you need any! Keep building and pioneering the world of web3!