Build a Simple Discord Bot for Neo N3 On-Chain Events

Pull vs Push

There are 2 main methods to get on-chain events in Neo N3.

Pulling involves constantly querying an RPC server for new blocks and checking each new block for events.

Pushing involves subscribing to get notified about specific events via WebSockets (only available via a neo-go node).

Pulling

We will be building a discord bot to alert us when 2 specific GhostMarket events occur:

  • the creation of a new order
  • the sale of an existing order

Neo N3 nodes expose an RPC server which we can query to get information about events that occur on the blockchain.

We will be constantly querying if there is a new block. For each new block, we will check if specific events occur.

To get started with querying on-chain events from Neo N3, we’ll be using the neon-core package from CoZ.

yarn add @cityofzion/neon-core

The neon-core package exports an rpc module which we can use to query for information from a node.

Querying on-chain events

The code below gets the current block from the blockchain.

import { rpc } from "@cityofzion/neon-core";

// Function to get latest block height
const getBlockHeight = async (rpc: rpc.RPCClient) => {
  const result = await rpc.getBlockCount().catch((error) => {
    console.log(`Unable to getBlockCount`);
    console.log(error.toString());
    return 0;
  });
  const blockHeight = result - 1;
  return blockHeight;
};

We maintain a constant loop of getting the current block and trigger some action when a new block is found.

// Declare new RPC client with a node URL
const rpcClient = new rpc.RPCClient("http://seed3.neo.org:10332");

let currentBlock = 0;

// Loop forever
const mainLoop = async () => {
  while (true) {
    let blockHeight = await getBlockHeight(rpcClient);
    console.log(`Current block: ${blockHeight}`);
    if (currentBlock < blockHeight) {
      console.log(`New block found: ${blockHeight}`);
      currentBlock = blockHeight;
    }
  }
};

mainLoop();

Neo N3 creates new blocks about every 15 seconds. We add a function to sleep for 10-15 seconds between each loop.

const sleep = (ms: number) => {
  return new Promise((r) => setTimeout(r, ms));
};

const mainLoop = async () => {
  while (true) {
    let blockHeight = await getBlockHeight(rpcClient);
    console.log(`Current block: ${currentBlock}`);
    if (currentBlock < blockHeight) {
      console.log(`New block found: ${blockHeight}`);
      currentBlock = blockHeight;
    }
    await sleep(10000);
  }
};

Let’s grab the transactions within the new block, if any, and identify if the event we are looking for has occurred by checking the transaction notifications.

const getBlockMessages = async (rpc: rpc.RPCClient, block: number) => {
  let blockMessages = await rpc.getBlock(block, true);
  if (!blockMessages.tx) {
    // some nodes return null instead of empty array, we standardize the implementation
    blockMessages.tx = [];
  }
  return blockMessages;
};

export type TransactionNotification = {
  txid: string;
  contract: string;
  eventname: string;
  state: sc.ContractParamJson;
};

const getNotificationsFromBlock = async (
  rpc: RPCClient,
  blockMessages: BlockJson
) => {
  let notificationsArray: TransactionNotification[] = [];
  // Get list of notifications
  for (let i = 0; i < blockMessages.tx.length; i++) {
    const tx = blockMessages.tx[i];
    let notifications: TransactionNotification[] = [];
    if (tx.hash) {
      let log = await rpc.getApplicationLog(tx.hash);
      log.executions.map((execution) => {
        if (
          execution.trigger === "Application" &&
          execution.vmstate === "HALT"
        ) {
          let notificationsWithTxId = execution.notifications.map(
            (notification) => {
              return {
                txid: log!.txid,
                contract: notification.contract,
                eventname: notification.eventname,
                state: notification.state,
              };
            }
          );
          notifications = notifications.concat(notificationsWithTxId);
        }
      });
      notificationsArray = notificationsArray.concat(notifications);
    }
  }
  return notificationsArray;
};

// Loop forever
const mainLoop = async () => {
  while (true) {
    let blockHeight = await getBlockHeight(rpcClient);
    console.log(`Current block: ${currentBlock}`);
    if (currentBlock < blockHeight) {
      console.log(`New block found: ${blockHeight}`);
      currentBlock = blockHeight;
      try {
        const blockMessages = await getBlockMessages(rpcClient, currentBlock);
        const notifications = getNotificationsFromBlock(
          rpcClient,
          blockMessages
        );
      } catch (error) {
        console.log(`Error getting block notifications: ${error}`);
      }
    }
    await sleep(10000);
  }
};

mainLoop();

We then add another function to parse the transaction notifications to find the events we are interested in, OrderCreated and OrderFilled.

const GhostMarketContract = "0xcc638d55d99fc81295daccbaf722b84f179fb9c4";
const GhostMarketOrderCreatedNotification = "OrderCreated";
const GhostMarketOrderFilledNotification = "OrderFilled";

const parseNotifications = async (notifications: TransactionNotification[]) => {
  for (let notification of notifications) {
    if (notification.contract === GhostMarketContract) {
      switch (notification.eventname) {
        case GhostMarketOrderCreatedNotification:
          break;
        case GhostMarketOrderFilledNotification:
          break;
        default:
          break;
      }
    }
  }
};

As smart contract creators can define their notification structure and parameters, we need to identify the meaning of each parameter in the notification sent by the GhostMarket smart contract.

One way of doing that is through OneGate explorer, provided the smart contract creator has added meaningful parameter names.

Take a look at GhostMarket’s contract on OneGate

Looking at the OrderCreated and OrderFilled events, we can see the list of parameters and their names. Based on this information, we can parse the notification to get the data we need to create our alerts.

We add several helper functions to convert raw byte strings in the transaction notification to readable values.

import { rpc, sc, u } from '@cityofzion/neon-core'
//...
export function byteStringToScriptHash(byteString: string): string {
  return (
    "0x" + u.HexString.fromBase64(byteString, true).toBigEndian().toString()
  );
}

export const ByteStringToBigInteger = (bytestring: string) => {
  return u.BigInteger.fromHex(
    u.HexString.fromBase64(bytestring, true).toString()
  ).toString();
};

export const ParamToBigInteger = (param: sc.ContractParamJson) => {
  if (!param.value) {
    return "";
  }
  switch (param.type) {
    case "Integer":
      return param.value.toString();
    case "ByteString":
      return ByteStringToBigInteger(param.value.toString());
    default:
      return param.value.toString();
  }
};

export type GhostMarketOrder = {
  price: number;
  endPrice: number;
  tokenScriptHash: string;
  tokenIdBigInt: string;
};

const parseGhostMarketOrderNotification = (
  notificationState: sc.ContractParamJson
): GhostMarketOrder => {
  const params = notificationState.value as sc.ContractParamJson[];
  if (
    !(params[1].value && params[2].value && params[4].value && params[5].value)
  ) {
    throw new Error("Invalid param values");
  }
  const baseScriptHash = byteStringToScriptHash(params[2].value.toString());
  const tokenIdBigInt = ParamToBigInteger(params[1]);
  const price = parseInt(params[4].value.toString());
  const endPrice = parseInt(params[5].value.toString());

  return {
    price: price,
    endPrice: endPrice,
    tokenScriptHash: baseScriptHash,
    tokenIdBigInt: tokenIdBigInt,
  };
};

We can then loop through and parse every relevant notification and create an alert to be sent via webhooks.

// ...
const webhookUrl = "WEBHOOK_URL";

const parseNotifications = async (notifications: TransactionNotification[]) => {
  for (let notification of notifications) {
    if (notification.contract === GhostMarketContract) {
      switch (notification.eventname) {
        case GhostMarketOrderCreatedNotification:
          const orderCreated = parseGhostMarketOrderNotification(
            notification.state
          );
          sendDiscordListingsNotification(orderCreated, webhookUrl);
          break;
        case GhostMarketOrderFilledNotification:
          const orderFilled = parseGhostMarketOrderNotification(
            notification.state
          );
          sendDiscordSalesNotification(orderFilled, webhookUrl);
          break;
        default:
          break;
      }
    }
  }
};
// ...
const sendDiscordListingsNotification = async (
  order: GhostMarketOrder,
  discordWebHook: string
) => {
  const Hook = new Webhook(discordWebHook);

  const msg = new MessageBuilder()
    .setName("Listing Created!")
    .setTitle(`${order.tokenScriptHash}`)
    .setDescription(
      `New listing created for ${order.tokenIdBigInt}.
    Price: ${order.price}`
    )
    .setURL(
      `https://ghostmarket.io/asset/n3/${order.tokenScriptHash}/${order.tokenIdBigInt}/`
    );

  Hook.send(msg).catch((error) => {
    console.log(error);
  });
};

const sendDiscordSalesNotification = async (
  order: GhostMarketOrder,
  discordWebHook: string
) => {
  const Hook = new Webhook(discordWebHook);

  const msg = new MessageBuilder()
    .setName("Token Sold!")
    .setTitle(`${order.tokenScriptHash}`)
    .setDescription(
      `The token ${order.tokenIdBigInt} was sold for ${order.price}!`
    )
    .setURL(
      `https://ghostmarket.io/asset/n3/${order.tokenScriptHash}/${order.tokenIdBigInt}/`
    );

  Hook.send(msg).catch((error) => {
    console.log(error);
  });
};
// ...
// Loop forever
const mainLoop = async () => {
  while (true) {
    let blockHeight = await getBlockHeight(rpcClient);
    console.log(`Current block: ${currentBlock}`);
    if (currentBlock < blockHeight) {
      console.log(`New block found: ${blockHeight}`);
      currentBlock = blockHeight;
      try {
        const blockMessages = await getBlockMessages(rpcClient, currentBlock);
        const notifications = await getNotificationsFromBlock(
          rpcClient,
          blockMessages
        );
        parseNotifications(notifications);
      } catch (error) {
        console.log(`Error getting block notifications: ${error}`);
      }
    }
    await sleep(10000);
  }
};