diff --git a/apps/ecash-herald/.eslintrc.js b/apps/ecash-herald/.eslintrc.js
--- a/apps/ecash-herald/.eslintrc.js
+++ b/apps/ecash-herald/.eslintrc.js
@@ -1,9 +1,7 @@
-// Copyright (c) 2023 The Bitcoin developers
+// Copyright (c) 2024 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-
 const headerArray = [
     {
         pattern:
@@ -22,13 +20,24 @@
         mocha: true,
     },
     overrides: [],
-    extends: 'eslint:recommended',
+    extends: [
+        'eslint:recommended',
+        'plugin:@typescript-eslint/recommended', // Add TypeScript recommended rules
+    ],
     parserOptions: {
         ecmaVersion: 'latest',
+        sourceType: 'module', // If you're using ES modules
+        parser: '@typescript-eslint/parser', // Use the TS parser
     },
-    plugins: ['header'],
+    plugins: [
+        'header',
+        '@typescript-eslint', // Add the TypeScript plugin
+    ],
     rules: {
         'strict': 'error',
         'header/header': [2, 'line', headerArray, 2],
+        // Disable rules overridden by TypeScript
+        'no-use-before-define': 'off',
+        '@typescript-eslint/no-use-before-define': ['error'],
     },
 };
diff --git a/apps/ecash-herald/.gitignore b/apps/ecash-herald/.gitignore
--- a/apps/ecash-herald/.gitignore
+++ b/apps/ecash-herald/.gitignore
@@ -1,9 +1,12 @@
 node_modules/
+dist/
 generated/
 secrets.js
+secrets.ts
 
 # Coverage reports with  nyc
 .nyc_output/
 
 # junit test results
 test-results.xml
+test_results/
diff --git a/apps/ecash-herald/.mocharc.js b/apps/ecash-herald/.mocharc.js
--- a/apps/ecash-herald/.mocharc.js
+++ b/apps/ecash-herald/.mocharc.js
@@ -1,5 +1,10 @@
-'use strict';
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
+'use strict';
 module.exports = {
-    require: 'mocha-suppress-logs',
+    require: ['mocha-suppress-logs', 'ts-node/register'],
+    extensions: ['ts'],
+    spec: ['test/**/*.test.ts'],
 };
diff --git a/apps/ecash-herald/README.md b/apps/ecash-herald/README.md
--- a/apps/ecash-herald/README.md
+++ b/apps/ecash-herald/README.md
@@ -1,6 +1,6 @@
 # ecash-herald
 
-A telegram bot to broadcast ecash chain activity
+A telegram bot to broadcast eCash chain activity
 
 ## development
 
@@ -10,16 +10,17 @@
 2. `cp secrets.sample.js secrets.js`
 3. Get telegram bot API keys from https://t.me/BotFather
 4. Create your own Telegram channel and invite your bot there.
-5. Fill out `secrets.js` with information for your telegram bot and channel
-6. `node index.js`
+5. Fill out `secrets.ts` with information for your telegram bot and channel
+6. `ts-node index.js` (or `npm run build`, then `node dist/index.js`)
 
 ## working on the app
 
 Because app performance is ultimately tied to the aesthetic readout of generated msgs, the actual format of generated messages must also be reviewed.
 
 1. Get telegram bot API keys from https://t.me/BotFather
-2. `cp secrets.sample.js secrets.js` and fill out with your Telegram bot information
+2. `cp secrets.sample.ts secrets.ts` and fill out with your Telegram bot information
 3. To test changes to the app, run `npm run generateMock`. This will build and broadcast telegram msg strings for a mocked block containing txids listed in `scripts/generateMock`.
 4. If your diff includes new features that are not covered by this mocked block, add relevant txids to the `txids` array in `scripts/generateMock.js`. You may also need to update `outputscriptInfoMap` and `tokenInfoMap` in `test/mocks/block.js`.
 5. If test messages look good, run `npm test` to confirm all unit tests still pass
 6. Run `npm run sendMsgByBlock <blockheight>` to review a msg for a specific block using live API calls
+7. Run `ts-node scripts/getDailySummary` to review txs from the last 24 hrs
diff --git a/apps/ecash-herald/config.js b/apps/ecash-herald/config.ts
rename from apps/ecash-herald/config.js
rename to apps/ecash-herald/config.ts
--- a/apps/ecash-herald/config.js
+++ b/apps/ecash-herald/config.ts
@@ -1,9 +1,96 @@
 // Copyright (c) 2023 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
+import { SendMessageOptions } from 'node-telegram-bot-api';
 
-'use strict';
-module.exports = {
+interface CryptoSlug {
+    coingeckoSlug: string;
+    ticker: string;
+}
+export type FiatCode = 'usd' | 'eur' | 'gbp' | 'jpy';
+export interface HeraldPriceApi {
+    apiBase: string;
+    cryptos: CryptoSlug[];
+    fiat: FiatCode;
+    precision: number;
+}
+export interface HeraldConfig {
+    cacheTtlMsecs: number;
+    xecSendDisplayCount: number;
+    chronik: string[];
+    blockExplorer: string;
+    priceApi: HeraldPriceApi;
+    fiatReference: { usd: string; jpy: string; eur: string; gbp: string };
+    stakingRewardApiUrl: string;
+    ifpAddress: string;
+    tgMsgOptions: SendMessageOptions;
+    whaleSats: {
+        bigWhale: number;
+        // 10 billion xec
+        modestWhale: number;
+        // 5 billion xec
+        shark: number;
+        // 1 billion xec
+        swordfish: number;
+        // 700 million xec
+        barracuda: number;
+        // 500 million xec
+        octopus: number;
+        // 250 million xec
+        piranha: number;
+        // 100 million xec
+        crab: number;
+        // anything under 100 million xec
+        shrimp: number;
+    };
+    emojis: {
+        agora: string;
+        agoraBuy: string;
+        agoraList: string;
+        agoraCancel: string;
+        alias: string;
+        alp: string;
+        invalid: string;
+        nft: string;
+        mintvault: string;
+        block: string;
+        miner: string;
+        staker: string;
+        xecSend: string;
+        arrowRight: string;
+        tokenBurn: string;
+        tokenGenesis: string;
+        tokenSend: string;
+        tokenMint: string;
+        tokenFixed: string;
+        gift: string;
+        bank: string;
+        app: string;
+        token: string;
+        fusion: string;
+        cashtabMsg: string;
+        cashtabEncrypted: string;
+        payButton: string;
+        swap: string;
+        airdrop: string;
+        paywall: string;
+        authentication: string;
+        unknown: string;
+        memo: string;
+        bigWhale: string;
+        modestWhale: string;
+        shark: string;
+        swordfish: string;
+        barracuda: string;
+        octopus: string;
+        piranha: string;
+        crab: string;
+        shrimp: string;
+        priceUp: string;
+        priceDown: string;
+    };
+}
+const config: HeraldConfig = {
     cacheTtlMsecs: 1000 * 60 * 60 * 4, // 4 hours
     xecSendDisplayCount: 12,
     chronik: [
@@ -99,3 +186,5 @@
         priceDown: '📉',
     },
 };
+
+export default config;
diff --git a/apps/ecash-herald/constants/addresses.js b/apps/ecash-herald/constants/addresses.ts
rename from apps/ecash-herald/constants/addresses.js
rename to apps/ecash-herald/constants/addresses.ts
--- a/apps/ecash-herald/constants/addresses.js
+++ b/apps/ecash-herald/constants/addresses.ts
@@ -2,10 +2,8 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-
 /**
- * addresses.js
+ * addresses.ts
  * A developer-updated directory of known eCash addresses
  * Tags below are added on a 'best guess' basis and are not necessarily confirmed
  *
@@ -13,7 +11,7 @@
  * instead of an eCash address slice preview for known addresses
  */
 
-const addressDirectory = new Map();
+const addressDirectory: Map<string, { tag: string }> = new Map();
 
 // Binance
 addressDirectory.set('ecash:qq337uy8jdmgg7gdzpyjjne6a7w0k7c9m5m5gnpx4u', {
@@ -25,4 +23,5 @@
 addressDirectory.set('ecash:qqv2vqz6he83x9pczvt552fuxnvhevlt6ugrqqa7w5', {
     tag: 'Coinex 2',
 });
-module.exports = addressDirectory;
+
+export default addressDirectory;
diff --git a/apps/ecash-herald/constants/lokad.js b/apps/ecash-herald/constants/lokad.ts
rename from apps/ecash-herald/constants/lokad.js
rename to apps/ecash-herald/constants/lokad.ts
--- a/apps/ecash-herald/constants/lokad.js
+++ b/apps/ecash-herald/constants/lokad.ts
@@ -2,14 +2,19 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-
 /**
  * Create a map of lokad IDs to app name used for daily summaries and, soon, tweets
  * Ultimately this should replace the knownApps in constants/op_return.js
  * However the parsing needs are distinct for daily summary vs block msgs
  */
-const lokadMap = new Map();
+
+interface LokadInfo {
+    name: string;
+    emoji: string;
+    url?: string;
+}
+type LokadMap = Map<string, LokadInfo>;
+const lokadMap: LokadMap = new Map();
 
 lokadMap.set('64726f70', { name: 'Airdrop', emoji: '🪂' });
 lokadMap.set('00746162', { name: 'Cashtab Msg', emoji: '✏️' });
@@ -28,4 +33,4 @@
     url: 'https://www.ecashchat.com/',
 });
 
-module.exports = lokadMap;
+export default lokadMap;
diff --git a/apps/ecash-herald/constants/miners.js b/apps/ecash-herald/constants/miners.ts
rename from apps/ecash-herald/constants/miners.js
rename to apps/ecash-herald/constants/miners.ts
--- a/apps/ecash-herald/constants/miners.js
+++ b/apps/ecash-herald/constants/miners.ts
@@ -2,14 +2,26 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
 /**
  * miners.js
  * Constants related to parsing for known miners of ecash blocks
  *
  * Store as a map keyed by outputScript
  */
-module.exports = {
+export interface MinerInfo {
+    miner: string;
+    coinbaseHexFragment: string;
+    parseableCoinbase?: boolean; // Note: Added optional property since it's not present in all entries
+}
+
+export interface Miners {
+    dataType: 'Map';
+    value: Array<[string, MinerInfo]>;
+}
+
+export type KnownMiners = Map<string, MinerInfo>;
+
+const miners: Miners = {
     dataType: 'Map',
     value: [
         [
@@ -114,3 +126,5 @@
         ],
     ],
 };
+
+export default miners;
diff --git a/apps/ecash-herald/constants/op_return.js b/apps/ecash-herald/constants/op_return.ts
rename from apps/ecash-herald/constants/op_return.js
rename to apps/ecash-herald/constants/op_return.ts
--- a/apps/ecash-herald/constants/op_return.js
+++ b/apps/ecash-herald/constants/op_return.ts
@@ -2,13 +2,31 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
+interface KnownApp {
+    prefix: string;
+    app: string;
+}
+
+interface MemoApp extends Record<string, string> {
+    prefix: string;
+    app: string;
+}
+
+interface OpReturnConstants {
+    opReturnPrefix: string;
+    opReturnAppPrefixLength: string;
+    opPushDataOne: string;
+    opReserved: string;
+    knownApps: Record<string, KnownApp>;
+    memo: MemoApp;
+}
+
 /**
  * op_return.js
  * Constants related to OP_RETURN script
  * https://en.bitcoin.it/wiki/Script
  */
-module.exports = {
+const opReturnConstants: OpReturnConstants = {
     opReturnPrefix: '6a',
     opReturnAppPrefixLength: '04',
     opPushDataOne: '4c',
@@ -60,3 +78,5 @@
         '26': 'Set address alias',
     },
 };
+
+export default opReturnConstants;
diff --git a/apps/ecash-herald/constants/senders.js b/apps/ecash-herald/constants/senders.js
deleted file mode 100644
--- a/apps/ecash-herald/constants/senders.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (c) 2024 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-module.exports = {
-    BINANCE_OUTPUTSCRIPT: '76a914231f7087937684790d1049294f3aef9cfb7b05dd88ac',
-    TOKEN_SERVER_OUTPUTSCRIPT:
-        '76a914821407ac2993f8684227004f4086082f3f801da788ac',
-};
diff --git a/apps/ecash-herald/constants/senders.ts b/apps/ecash-herald/constants/senders.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/constants/senders.ts
@@ -0,0 +1,9 @@
+// Copyright (c) 2024 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+export const BINANCE_OUTPUTSCRIPT =
+    '76a914231f7087937684790d1049294f3aef9cfb7b05dd88ac';
+
+export const TOKEN_SERVER_OUTPUTSCRIPT =
+    '76a914821407ac2993f8684227004f4086082f3f801da788ac';
diff --git a/apps/ecash-herald/constants/tokens.js b/apps/ecash-herald/constants/tokens.ts
rename from apps/ecash-herald/constants/tokens.js
rename to apps/ecash-herald/constants/tokens.ts
--- a/apps/ecash-herald/constants/tokens.js
+++ b/apps/ecash-herald/constants/tokens.ts
@@ -2,8 +2,7 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-
+import { GenesisInfo } from 'chronik-client';
 /**
  * tokens.js
  *
@@ -12,7 +11,8 @@
  * However primary driver here is to cover slp2 tokens which are not yet indexed and readily add-able
  */
 
-const cachedTokenInfoMap = new Map();
+type TokenInfoMap = Map<string, GenesisInfo>;
+const cachedTokenInfoMap: TokenInfoMap = new Map();
 cachedTokenInfoMap.set(
     'cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145',
     {
@@ -20,10 +20,9 @@
         tokenName: 'Credo In Unum Deo',
         url: 'https://crd.network/token',
         decimals: 4,
-        data: {},
         authPubkey:
             '0334b744e6338ad438c92900c0ed1869c3fd2c0f35a4a9b97a88447b6e2b145f10',
     },
 );
 
-module.exports = cachedTokenInfoMap;
+export default cachedTokenInfoMap;
diff --git a/apps/ecash-herald/index.js b/apps/ecash-herald/index.ts
rename from apps/ecash-herald/index.js
rename to apps/ecash-herald/index.ts
--- a/apps/ecash-herald/index.js
+++ b/apps/ecash-herald/index.ts
@@ -2,23 +2,23 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const config = require('./config');
-const { ChronikClient } = require('chronik-client');
-// Initialize chronik on app startup
-const chronik = new ChronikClient(config.chronik);
+import { ChronikClient } from 'chronik-client';
+import { CronJob } from 'cron';
+import TelegramBot from 'node-telegram-bot-api';
+import secrets from './secrets';
+import config from './config';
+import { main } from './src/main';
+import { handleUtcMidnight } from './src/events';
+
 // Initialize telegram bot on app startup
-const secrets = require('./secrets');
-const TelegramBot = require('node-telegram-bot-api');
 const { botId, channelId, dailyChannelId } = secrets.prod.telegram;
-const cron = require('cron');
-// Create a bot that uses 'polling' to fetch new updates
 const telegramBot = new TelegramBot(botId, { polling: true });
-const { main } = require('./src/main');
-const { handleUtcMidnight } = require('./src/events');
+
+// Initialize chronik on app startup
+const chronik = new ChronikClient(config.chronik);
 
 // Cron job for daily summaries
-const job = new cron.CronJob(
+const job = new CronJob(
     // see https://www.npmjs.com/package/cron
     // seconds[0-59] minutes[0-59] hours[0-23] day-of-month[1-31] month[1-12] day-of-week[0-7]
     '0 0 0 * * *', // cronTime
diff --git a/apps/ecash-herald/package-lock.json b/apps/ecash-herald/package-lock.json
--- a/apps/ecash-herald/package-lock.json
+++ b/apps/ecash-herald/package-lock.json
@@ -1,12 +1,12 @@
 {
     "name": "ecash-herald",
-    "version": "1.0.2",
+    "version": "2.0.0",
     "lockfileVersion": 2,
     "requires": true,
     "packages": {
         "": {
             "name": "ecash-herald",
-            "version": "1.0.2",
+            "version": "2.0.0",
             "license": "MIT",
             "dependencies": {
                 "axios": "^1.3.4",
@@ -23,12 +23,19 @@
             },
             "devDependencies": {
                 "@sinonjs/fake-timers": "^11.2.2",
+                "@types/mocha": "^10.0.9",
+                "@types/node-telegram-bot-api": "^0.64.7",
+                "@types/sinonjs__fake-timers": "^8.1.5",
+                "@typescript-eslint/eslint-plugin": "^8.12.2",
+                "@typescript-eslint/parser": "^8.12.2",
                 "eslint": "^8.37.0",
                 "eslint-plugin-header": "^3.1.1",
                 "mocha": "^10.2.0",
                 "mocha-junit-reporter": "^2.2.0",
                 "mocha-suppress-logs": "^0.3.1",
-                "nyc": "^15.1.0"
+                "nyc": "^15.1.0",
+                "ts-node": "^10.9.2",
+                "typescript": "^5.6.3"
             }
         },
         "../../modules/chronik-client": {
@@ -669,6 +676,30 @@
                 "node": ">=6.9.0"
             }
         },
+        "node_modules/@cspotcode/source-map-support": {
+            "version": "0.8.1",
+            "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+            "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/trace-mapping": "0.3.9"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+            "version": "0.3.9",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+            "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/resolve-uri": "^3.0.3",
+                "@jridgewell/sourcemap-codec": "^1.4.10"
+            }
+        },
         "node_modules/@cypress/request": {
             "version": "3.0.1",
             "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz",
@@ -732,23 +763,25 @@
             }
         },
         "node_modules/@eslint-community/regexpp": {
-            "version": "4.5.0",
-            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz",
-            "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==",
+            "version": "4.12.1",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+            "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
             }
         },
         "node_modules/@eslint/eslintrc": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz",
-            "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==",
+            "version": "2.1.4",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+            "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "ajv": "^6.12.4",
                 "debug": "^4.3.2",
-                "espree": "^9.5.1",
+                "espree": "^9.6.0",
                 "globals": "^13.19.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
@@ -768,18 +801,20 @@
             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
             "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "balanced-match": "^1.0.0",
                 "concat-map": "0.0.1"
             }
         },
         "node_modules/@eslint/eslintrc/node_modules/debug": {
-            "version": "4.3.4",
-            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
-            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "ms": "2.1.2"
+                "ms": "^2.1.3"
             },
             "engines": {
                 "node": ">=6.0"
@@ -795,6 +830,7 @@
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
             "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
             "dev": true,
+            "license": "ISC",
             "dependencies": {
                 "brace-expansion": "^1.1.7"
             },
@@ -802,29 +838,26 @@
                 "node": "*"
             }
         },
-        "node_modules/@eslint/eslintrc/node_modules/ms": {
-            "version": "2.1.2",
-            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-            "dev": true
-        },
         "node_modules/@eslint/js": {
-            "version": "8.37.0",
-            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.37.0.tgz",
-            "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==",
+            "version": "8.57.1",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+            "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             }
         },
         "node_modules/@humanwhocodes/config-array": {
-            "version": "0.11.8",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
-            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+            "version": "0.13.0",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+            "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
+            "deprecated": "Use @eslint/config-array instead",
             "dev": true,
+            "license": "Apache-2.0",
             "dependencies": {
-                "@humanwhocodes/object-schema": "^1.2.1",
-                "debug": "^4.1.1",
+                "@humanwhocodes/object-schema": "^2.0.3",
+                "debug": "^4.3.1",
                 "minimatch": "^3.0.5"
             },
             "engines": {
@@ -836,18 +869,20 @@
             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
             "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "balanced-match": "^1.0.0",
                 "concat-map": "0.0.1"
             }
         },
         "node_modules/@humanwhocodes/config-array/node_modules/debug": {
-            "version": "4.3.4",
-            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
-            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "ms": "2.1.2"
+                "ms": "^2.1.3"
             },
             "engines": {
                 "node": ">=6.0"
@@ -863,6 +898,7 @@
             "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
             "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
             "dev": true,
+            "license": "ISC",
             "dependencies": {
                 "brace-expansion": "^1.1.7"
             },
@@ -870,12 +906,6 @@
                 "node": "*"
             }
         },
-        "node_modules/@humanwhocodes/config-array/node_modules/ms": {
-            "version": "2.1.2",
-            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-            "dev": true
-        },
         "node_modules/@humanwhocodes/module-importer": {
             "version": "1.0.1",
             "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -890,10 +920,12 @@
             }
         },
         "node_modules/@humanwhocodes/object-schema": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
-            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
-            "dev": true
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+            "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+            "deprecated": "Use @eslint/object-schema instead",
+            "dev": true,
+            "license": "BSD-3-Clause"
         },
         "node_modules/@istanbuljs/load-nyc-config": {
             "version": "1.1.0",
@@ -1114,17 +1146,404 @@
                 "@sinonjs/commons": "^3.0.0"
             }
         },
+        "node_modules/@tsconfig/node10": {
+            "version": "1.0.11",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+            "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@tsconfig/node12": {
+            "version": "1.0.11",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+            "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@tsconfig/node14": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+            "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@tsconfig/node16": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+            "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@types/caseless": {
+            "version": "0.12.5",
+            "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
+            "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/@types/luxon": {
             "version": "3.4.2",
             "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
             "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
             "license": "MIT"
         },
+        "node_modules/@types/mocha": {
+            "version": "10.0.9",
+            "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.9.tgz",
+            "integrity": "sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@types/node": {
+            "version": "22.8.6",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.6.tgz",
+            "integrity": "sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "undici-types": "~6.19.8"
+            }
+        },
+        "node_modules/@types/node-telegram-bot-api": {
+            "version": "0.64.7",
+            "resolved": "https://registry.npmjs.org/@types/node-telegram-bot-api/-/node-telegram-bot-api-0.64.7.tgz",
+            "integrity": "sha512-nuvFFXnvU2sItucyEJ03I+m34z5st386isfEuF6BJTL7p3RjG7naMhvvXjY7oeKahTm1Jf0Gu4PrDa6jDt78/Q==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/node": "*",
+                "@types/request": "*"
+            }
+        },
+        "node_modules/@types/request": {
+            "version": "2.48.12",
+            "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz",
+            "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/caseless": "*",
+                "@types/node": "*",
+                "@types/tough-cookie": "*",
+                "form-data": "^2.5.0"
+            }
+        },
+        "node_modules/@types/request/node_modules/form-data": {
+            "version": "2.5.2",
+            "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.2.tgz",
+            "integrity": "sha512-GgwY0PS7DbXqajuGf4OYlsrIu3zgxD6Vvql43IBhm6MahqA5SK/7mwhtNj2AdH2z35YR34ujJ7BN+3fFC3jP5Q==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "asynckit": "^0.4.0",
+                "combined-stream": "^1.0.6",
+                "mime-types": "^2.1.12",
+                "safe-buffer": "^5.2.1"
+            },
+            "engines": {
+                "node": ">= 0.12"
+            }
+        },
+        "node_modules/@types/sinonjs__fake-timers": {
+            "version": "8.1.5",
+            "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz",
+            "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@types/tough-cookie": {
+            "version": "4.0.5",
+            "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+            "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@typescript-eslint/eslint-plugin": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz",
+            "integrity": "sha512-gQxbxM8mcxBwaEmWdtLCIGLfixBMHhQjBqR8sVWNTPpcj45WlYL2IObS/DNMLH1DBP0n8qz+aiiLTGfopPEebw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@eslint-community/regexpp": "^4.10.0",
+                "@typescript-eslint/scope-manager": "8.12.2",
+                "@typescript-eslint/type-utils": "8.12.2",
+                "@typescript-eslint/utils": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2",
+                "graphemer": "^1.4.0",
+                "ignore": "^5.3.1",
+                "natural-compare": "^1.4.0",
+                "ts-api-utils": "^1.3.0"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            },
+            "peerDependencies": {
+                "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
+                "eslint": "^8.57.0 || ^9.0.0"
+            },
+            "peerDependenciesMeta": {
+                "typescript": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@typescript-eslint/parser": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.12.2.tgz",
+            "integrity": "sha512-MrvlXNfGPLH3Z+r7Tk+Z5moZAc0dzdVjTgUgwsdGweH7lydysQsnSww3nAmsq8blFuRD5VRlAr9YdEFw3e6PBw==",
+            "dev": true,
+            "license": "BSD-2-Clause",
+            "dependencies": {
+                "@typescript-eslint/scope-manager": "8.12.2",
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/typescript-estree": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2",
+                "debug": "^4.3.4"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            },
+            "peerDependencies": {
+                "eslint": "^8.57.0 || ^9.0.0"
+            },
+            "peerDependenciesMeta": {
+                "typescript": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@typescript-eslint/parser/node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@typescript-eslint/scope-manager": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.12.2.tgz",
+            "integrity": "sha512-gPLpLtrj9aMHOvxJkSbDBmbRuYdtiEbnvO25bCMza3DhMjTQw0u7Y1M+YR5JPbMsXXnSPuCf5hfq0nEkQDL/JQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            }
+        },
+        "node_modules/@typescript-eslint/type-utils": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.12.2.tgz",
+            "integrity": "sha512-bwuU4TAogPI+1q/IJSKuD4shBLc/d2vGcRT588q+jzayQyjVK2X6v/fbR4InY2U2sgf8MEvVCqEWUzYzgBNcGQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@typescript-eslint/typescript-estree": "8.12.2",
+                "@typescript-eslint/utils": "8.12.2",
+                "debug": "^4.3.4",
+                "ts-api-utils": "^1.3.0"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            },
+            "peerDependenciesMeta": {
+                "typescript": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@typescript-eslint/type-utils/node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@typescript-eslint/types": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.12.2.tgz",
+            "integrity": "sha512-VwDwMF1SZ7wPBUZwmMdnDJ6sIFk4K4s+ALKLP6aIQsISkPv8jhiw65sAK6SuWODN/ix+m+HgbYDkH+zLjrzvOA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            }
+        },
+        "node_modules/@typescript-eslint/typescript-estree": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.12.2.tgz",
+            "integrity": "sha512-mME5MDwGe30Pq9zKPvyduyU86PH7aixwqYR2grTglAdB+AN8xXQ1vFGpYaUSJ5o5P/5znsSBeNcs5g5/2aQwow==",
+            "dev": true,
+            "license": "BSD-2-Clause",
+            "dependencies": {
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2",
+                "debug": "^4.3.4",
+                "fast-glob": "^3.3.2",
+                "is-glob": "^4.0.3",
+                "minimatch": "^9.0.4",
+                "semver": "^7.6.0",
+                "ts-api-utils": "^1.3.0"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            },
+            "peerDependenciesMeta": {
+                "typescript": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+            "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "ms": "^2.1.3"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+            "version": "9.0.5",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+            "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+            "dev": true,
+            "license": "ISC",
+            "dependencies": {
+                "brace-expansion": "^2.0.1"
+            },
+            "engines": {
+                "node": ">=16 || 14 >=14.17"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+            "version": "7.6.3",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+            "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+            "dev": true,
+            "license": "ISC",
+            "bin": {
+                "semver": "bin/semver.js"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/@typescript-eslint/utils": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.12.2.tgz",
+            "integrity": "sha512-UTTuDIX3fkfAz6iSVa5rTuSfWIYZ6ATtEocQ/umkRSyC9O919lbZ8dcH7mysshrCdrAM03skJOEYaBugxN+M6A==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@eslint-community/eslint-utils": "^4.4.0",
+                "@typescript-eslint/scope-manager": "8.12.2",
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/typescript-estree": "8.12.2"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            },
+            "peerDependencies": {
+                "eslint": "^8.57.0 || ^9.0.0"
+            }
+        },
+        "node_modules/@typescript-eslint/visitor-keys": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.12.2.tgz",
+            "integrity": "sha512-PChz8UaKQAVNHghsHcPyx1OMHoFRUEA7rJSK/mDhdq85bk+PLsUHUBqTQTFt18VJZbmxBovM65fezlheQRsSDA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@typescript-eslint/types": "8.12.2",
+                "eslint-visitor-keys": "^3.4.3"
+            },
+            "engines": {
+                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/typescript-eslint"
+            }
+        },
+        "node_modules/@ungap/structured-clone": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+            "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+            "dev": true,
+            "license": "ISC"
+        },
         "node_modules/acorn": {
-            "version": "8.8.2",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
-            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "version": "8.14.0",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
+            "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
             "dev": true,
+            "license": "MIT",
             "bin": {
                 "acorn": "bin/acorn"
             },
@@ -1137,10 +1556,24 @@
             "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
             "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
             "dev": true,
+            "license": "MIT",
             "peerDependencies": {
                 "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
             }
         },
+        "node_modules/acorn-walk": {
+            "version": "8.3.4",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+            "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "acorn": "^8.11.0"
+            },
+            "engines": {
+                "node": ">=0.4.0"
+            }
+        },
         "node_modules/aggregate-error": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -1233,6 +1666,13 @@
             "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
             "dev": true
         },
+        "node_modules/arg": {
+            "version": "4.1.3",
+            "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+            "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/argparse": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1519,6 +1959,7 @@
             "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
             "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=6"
             }
@@ -1701,6 +2142,13 @@
             "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
             "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
         },
+        "node_modules/create-require": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+            "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/cron": {
             "version": "3.1.7",
             "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz",
@@ -1770,7 +2218,8 @@
             "version": "0.1.4",
             "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
             "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-            "dev": true
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/default-require-extensions": {
             "version": "3.0.1",
@@ -1988,27 +2437,30 @@
             }
         },
         "node_modules/eslint": {
-            "version": "8.37.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
-            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
+            "version": "8.57.1",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+            "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
+            "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "@eslint-community/eslint-utils": "^4.2.0",
-                "@eslint-community/regexpp": "^4.4.0",
-                "@eslint/eslintrc": "^2.0.2",
-                "@eslint/js": "8.37.0",
-                "@humanwhocodes/config-array": "^0.11.8",
+                "@eslint-community/regexpp": "^4.6.1",
+                "@eslint/eslintrc": "^2.1.4",
+                "@eslint/js": "8.57.1",
+                "@humanwhocodes/config-array": "^0.13.0",
                 "@humanwhocodes/module-importer": "^1.0.1",
                 "@nodelib/fs.walk": "^1.2.8",
-                "ajv": "^6.10.0",
+                "@ungap/structured-clone": "^1.2.0",
+                "ajv": "^6.12.4",
                 "chalk": "^4.0.0",
                 "cross-spawn": "^7.0.2",
                 "debug": "^4.3.2",
                 "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
-                "eslint-scope": "^7.1.1",
-                "eslint-visitor-keys": "^3.4.0",
-                "espree": "^9.5.1",
+                "eslint-scope": "^7.2.2",
+                "eslint-visitor-keys": "^3.4.3",
+                "espree": "^9.6.1",
                 "esquery": "^1.4.2",
                 "esutils": "^2.0.2",
                 "fast-deep-equal": "^3.1.3",
@@ -2016,22 +2468,19 @@
                 "find-up": "^5.0.0",
                 "glob-parent": "^6.0.2",
                 "globals": "^13.19.0",
-                "grapheme-splitter": "^1.0.4",
+                "graphemer": "^1.4.0",
                 "ignore": "^5.2.0",
-                "import-fresh": "^3.0.0",
                 "imurmurhash": "^0.1.4",
                 "is-glob": "^4.0.0",
                 "is-path-inside": "^3.0.3",
-                "js-sdsl": "^4.1.4",
                 "js-yaml": "^4.1.0",
                 "json-stable-stringify-without-jsonify": "^1.0.1",
                 "levn": "^0.4.1",
                 "lodash.merge": "^4.6.2",
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
-                "optionator": "^0.9.1",
+                "optionator": "^0.9.3",
                 "strip-ansi": "^6.0.1",
-                "strip-json-comments": "^3.1.0",
                 "text-table": "^0.2.0"
             },
             "bin": {
@@ -2054,23 +2503,28 @@
             }
         },
         "node_modules/eslint-scope": {
-            "version": "7.1.1",
-            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
-            "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+            "version": "7.2.2",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+            "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
             "dev": true,
+            "license": "BSD-2-Clause",
             "dependencies": {
                 "esrecurse": "^4.3.0",
                 "estraverse": "^5.2.0"
             },
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
             }
         },
         "node_modules/eslint-visitor-keys": {
-            "version": "3.4.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz",
-            "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==",
+            "version": "3.4.3",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+            "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
             "dev": true,
+            "license": "Apache-2.0",
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             },
@@ -2136,14 +2590,15 @@
             "dev": true
         },
         "node_modules/espree": {
-            "version": "9.5.1",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
-            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
+            "version": "9.6.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+            "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
             "dev": true,
+            "license": "BSD-2-Clause",
             "dependencies": {
-                "acorn": "^8.8.0",
+                "acorn": "^8.9.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.4.0"
+                "eslint-visitor-keys": "^3.4.1"
             },
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -2182,6 +2637,7 @@
             "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
             "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
             "dev": true,
+            "license": "BSD-2-Clause",
             "dependencies": {
                 "estraverse": "^5.2.0"
             },
@@ -2232,6 +2688,23 @@
             "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
             "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
         },
+        "node_modules/fast-glob": {
+            "version": "3.3.2",
+            "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+            "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@nodelib/fs.stat": "^2.0.2",
+                "@nodelib/fs.walk": "^1.2.3",
+                "glob-parent": "^5.1.2",
+                "merge2": "^1.3.0",
+                "micromatch": "^4.0.4"
+            },
+            "engines": {
+                "node": ">=8.6.0"
+            }
+        },
         "node_modules/fast-json-stable-stringify": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -2241,7 +2714,8 @@
             "version": "2.0.6",
             "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
             "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
-            "dev": true
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/fastq": {
             "version": "1.15.0",
@@ -2599,10 +3073,11 @@
             }
         },
         "node_modules/globals": {
-            "version": "13.20.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
-            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+            "version": "13.24.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+            "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "type-fest": "^0.20.2"
             },
@@ -2644,11 +3119,12 @@
             "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
             "dev": true
         },
-        "node_modules/grapheme-splitter": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
-            "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
-            "dev": true
+        "node_modules/graphemer": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+            "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/har-schema": {
             "version": "2.0.0",
@@ -2805,10 +3281,11 @@
             }
         },
         "node_modules/ignore": {
-            "version": "5.2.4",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
-            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+            "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">= 4"
             }
@@ -2818,6 +3295,7 @@
             "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
             "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "parent-module": "^1.0.0",
                 "resolve-from": "^4.0.0"
@@ -3307,16 +3785,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/js-sdsl": {
-            "version": "4.4.0",
-            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
-            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==",
-            "dev": true,
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/js-sdsl"
-            }
-        },
         "node_modules/js-tokens": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3408,6 +3876,7 @@
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
             "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "prelude-ls": "^1.2.1",
                 "type-check": "~0.4.0"
@@ -3504,15 +3973,46 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
+        "node_modules/make-error": {
+            "version": "1.3.6",
+            "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+            "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+            "dev": true,
+            "license": "ISC"
+        },
         "node_modules/md5": {
             "version": "2.3.0",
             "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
             "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
             "dev": true,
             "dependencies": {
-                "charenc": "0.0.2",
-                "crypt": "0.0.2",
-                "is-buffer": "~1.1.6"
+                "charenc": "0.0.2",
+                "crypt": "0.0.2",
+                "is-buffer": "~1.1.6"
+            }
+        },
+        "node_modules/merge2": {
+            "version": "1.4.1",
+            "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+            "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">= 8"
+            }
+        },
+        "node_modules/micromatch": {
+            "version": "4.0.8",
+            "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+            "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "braces": "^3.0.3",
+                "picomatch": "^2.3.1"
+            },
+            "engines": {
+                "node": ">=8.6"
             }
         },
         "node_modules/mime": {
@@ -3985,17 +4485,18 @@
             }
         },
         "node_modules/optionator": {
-            "version": "0.9.1",
-            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
-            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "version": "0.9.4",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+            "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
                 "levn": "^0.4.1",
                 "prelude-ls": "^1.2.1",
                 "type-check": "^0.4.0",
-                "word-wrap": "^1.2.3"
+                "word-wrap": "^1.2.5"
             },
             "engines": {
                 "node": ">= 0.8.0"
@@ -4072,6 +4573,7 @@
             "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
             "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "callsites": "^3.0.0"
             },
@@ -4199,6 +4701,7 @@
             "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
             "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">= 0.8.0"
             }
@@ -4507,6 +5010,7 @@
             "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
             "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=4"
             }
@@ -4907,6 +5411,73 @@
                 "node": ">=6"
             }
         },
+        "node_modules/ts-api-utils": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz",
+            "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=16"
+            },
+            "peerDependencies": {
+                "typescript": ">=4.2.0"
+            }
+        },
+        "node_modules/ts-node": {
+            "version": "10.9.2",
+            "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+            "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@cspotcode/source-map-support": "^0.8.0",
+                "@tsconfig/node10": "^1.0.7",
+                "@tsconfig/node12": "^1.0.7",
+                "@tsconfig/node14": "^1.0.0",
+                "@tsconfig/node16": "^1.0.2",
+                "acorn": "^8.4.1",
+                "acorn-walk": "^8.1.1",
+                "arg": "^4.1.0",
+                "create-require": "^1.1.0",
+                "diff": "^4.0.1",
+                "make-error": "^1.1.1",
+                "v8-compile-cache-lib": "^3.0.1",
+                "yn": "3.1.1"
+            },
+            "bin": {
+                "ts-node": "dist/bin.js",
+                "ts-node-cwd": "dist/bin-cwd.js",
+                "ts-node-esm": "dist/bin-esm.js",
+                "ts-node-script": "dist/bin-script.js",
+                "ts-node-transpile-only": "dist/bin-transpile.js",
+                "ts-script": "dist/bin-script-deprecated.js"
+            },
+            "peerDependencies": {
+                "@swc/core": ">=1.2.50",
+                "@swc/wasm": ">=1.2.50",
+                "@types/node": "*",
+                "typescript": ">=2.7"
+            },
+            "peerDependenciesMeta": {
+                "@swc/core": {
+                    "optional": true
+                },
+                "@swc/wasm": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/ts-node/node_modules/diff": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+            "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+            "dev": true,
+            "license": "BSD-3-Clause",
+            "engines": {
+                "node": ">=0.3.1"
+            }
+        },
         "node_modules/tunnel-agent": {
             "version": "0.6.0",
             "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -4930,6 +5501,7 @@
             "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
             "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "prelude-ls": "^1.2.1"
             },
@@ -4952,6 +5524,7 @@
             "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
             "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
             "dev": true,
+            "license": "(MIT OR CC0-1.0)",
             "engines": {
                 "node": ">=10"
             },
@@ -4981,6 +5554,20 @@
                 "is-typedarray": "^1.0.0"
             }
         },
+        "node_modules/typescript": {
+            "version": "5.6.3",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+            "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+            "dev": true,
+            "license": "Apache-2.0",
+            "bin": {
+                "tsc": "bin/tsc",
+                "tsserver": "bin/tsserver"
+            },
+            "engines": {
+                "node": ">=14.17"
+            }
+        },
         "node_modules/unbox-primitive": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -4995,6 +5582,13 @@
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/undici-types": {
+            "version": "6.19.8",
+            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+            "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/universalify": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
@@ -5062,6 +5656,13 @@
                 "uuid": "dist/bin/uuid"
             }
         },
+        "node_modules/v8-compile-cache-lib": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+            "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/verror": {
             "version": "1.10.0",
             "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
@@ -5142,6 +5743,7 @@
             "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
             "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=0.10.0"
             }
@@ -5249,6 +5851,16 @@
                 "node": ">=10"
             }
         },
+        "node_modules/yn": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+            "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
         "node_modules/yocto-queue": {
             "version": "0.1.0",
             "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -5660,6 +6272,27 @@
                 "to-fast-properties": "^2.0.0"
             }
         },
+        "@cspotcode/source-map-support": {
+            "version": "0.8.1",
+            "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+            "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+            "dev": true,
+            "requires": {
+                "@jridgewell/trace-mapping": "0.3.9"
+            },
+            "dependencies": {
+                "@jridgewell/trace-mapping": {
+                    "version": "0.3.9",
+                    "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+                    "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+                    "dev": true,
+                    "requires": {
+                        "@jridgewell/resolve-uri": "^3.0.3",
+                        "@jridgewell/sourcemap-codec": "^1.4.10"
+                    }
+                }
+            }
+        },
         "@cypress/request": {
             "version": "3.0.1",
             "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz",
@@ -5706,20 +6339,20 @@
             }
         },
         "@eslint-community/regexpp": {
-            "version": "4.5.0",
-            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz",
-            "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==",
+            "version": "4.12.1",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+            "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
             "dev": true
         },
         "@eslint/eslintrc": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz",
-            "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==",
+            "version": "2.1.4",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+            "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
             "dev": true,
             "requires": {
                 "ajv": "^6.12.4",
                 "debug": "^4.3.2",
-                "espree": "^9.5.1",
+                "espree": "^9.6.0",
                 "globals": "^13.19.0",
                 "ignore": "^5.2.0",
                 "import-fresh": "^3.2.1",
@@ -5739,12 +6372,12 @@
                     }
                 },
                 "debug": {
-                    "version": "4.3.4",
-                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
-                    "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+                    "version": "4.3.7",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+                    "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
                     "dev": true,
                     "requires": {
-                        "ms": "2.1.2"
+                        "ms": "^2.1.3"
                     }
                 },
                 "minimatch": {
@@ -5755,29 +6388,23 @@
                     "requires": {
                         "brace-expansion": "^1.1.7"
                     }
-                },
-                "ms": {
-                    "version": "2.1.2",
-                    "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-                    "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-                    "dev": true
                 }
             }
         },
         "@eslint/js": {
-            "version": "8.37.0",
-            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.37.0.tgz",
-            "integrity": "sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==",
+            "version": "8.57.1",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+            "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
             "dev": true
         },
         "@humanwhocodes/config-array": {
-            "version": "0.11.8",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
-            "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+            "version": "0.13.0",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+            "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
             "dev": true,
             "requires": {
-                "@humanwhocodes/object-schema": "^1.2.1",
-                "debug": "^4.1.1",
+                "@humanwhocodes/object-schema": "^2.0.3",
+                "debug": "^4.3.1",
                 "minimatch": "^3.0.5"
             },
             "dependencies": {
@@ -5792,12 +6419,12 @@
                     }
                 },
                 "debug": {
-                    "version": "4.3.4",
-                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
-                    "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+                    "version": "4.3.7",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+                    "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
                     "dev": true,
                     "requires": {
-                        "ms": "2.1.2"
+                        "ms": "^2.1.3"
                     }
                 },
                 "minimatch": {
@@ -5808,12 +6435,6 @@
                     "requires": {
                         "brace-expansion": "^1.1.7"
                     }
-                },
-                "ms": {
-                    "version": "2.1.2",
-                    "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-                    "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-                    "dev": true
                 }
             }
         },
@@ -5824,9 +6445,9 @@
             "dev": true
         },
         "@humanwhocodes/object-schema": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
-            "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+            "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
             "dev": true
         },
         "@istanbuljs/load-nyc-config": {
@@ -6000,15 +6621,258 @@
                 "@sinonjs/commons": "^3.0.0"
             }
         },
+        "@tsconfig/node10": {
+            "version": "1.0.11",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+            "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+            "dev": true
+        },
+        "@tsconfig/node12": {
+            "version": "1.0.11",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+            "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+            "dev": true
+        },
+        "@tsconfig/node14": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+            "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+            "dev": true
+        },
+        "@tsconfig/node16": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+            "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+            "dev": true
+        },
+        "@types/caseless": {
+            "version": "0.12.5",
+            "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
+            "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
+            "dev": true
+        },
         "@types/luxon": {
             "version": "3.4.2",
             "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
             "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA=="
         },
+        "@types/mocha": {
+            "version": "10.0.9",
+            "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.9.tgz",
+            "integrity": "sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==",
+            "dev": true
+        },
+        "@types/node": {
+            "version": "22.8.6",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.6.tgz",
+            "integrity": "sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw==",
+            "dev": true,
+            "requires": {
+                "undici-types": "~6.19.8"
+            }
+        },
+        "@types/node-telegram-bot-api": {
+            "version": "0.64.7",
+            "resolved": "https://registry.npmjs.org/@types/node-telegram-bot-api/-/node-telegram-bot-api-0.64.7.tgz",
+            "integrity": "sha512-nuvFFXnvU2sItucyEJ03I+m34z5st386isfEuF6BJTL7p3RjG7naMhvvXjY7oeKahTm1Jf0Gu4PrDa6jDt78/Q==",
+            "dev": true,
+            "requires": {
+                "@types/node": "*",
+                "@types/request": "*"
+            }
+        },
+        "@types/request": {
+            "version": "2.48.12",
+            "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz",
+            "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==",
+            "dev": true,
+            "requires": {
+                "@types/caseless": "*",
+                "@types/node": "*",
+                "@types/tough-cookie": "*",
+                "form-data": "^2.5.0"
+            },
+            "dependencies": {
+                "form-data": {
+                    "version": "2.5.2",
+                    "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.2.tgz",
+                    "integrity": "sha512-GgwY0PS7DbXqajuGf4OYlsrIu3zgxD6Vvql43IBhm6MahqA5SK/7mwhtNj2AdH2z35YR34ujJ7BN+3fFC3jP5Q==",
+                    "dev": true,
+                    "requires": {
+                        "asynckit": "^0.4.0",
+                        "combined-stream": "^1.0.6",
+                        "mime-types": "^2.1.12",
+                        "safe-buffer": "^5.2.1"
+                    }
+                }
+            }
+        },
+        "@types/sinonjs__fake-timers": {
+            "version": "8.1.5",
+            "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz",
+            "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==",
+            "dev": true
+        },
+        "@types/tough-cookie": {
+            "version": "4.0.5",
+            "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+            "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+            "dev": true
+        },
+        "@typescript-eslint/eslint-plugin": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz",
+            "integrity": "sha512-gQxbxM8mcxBwaEmWdtLCIGLfixBMHhQjBqR8sVWNTPpcj45WlYL2IObS/DNMLH1DBP0n8qz+aiiLTGfopPEebw==",
+            "dev": true,
+            "requires": {
+                "@eslint-community/regexpp": "^4.10.0",
+                "@typescript-eslint/scope-manager": "8.12.2",
+                "@typescript-eslint/type-utils": "8.12.2",
+                "@typescript-eslint/utils": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2",
+                "graphemer": "^1.4.0",
+                "ignore": "^5.3.1",
+                "natural-compare": "^1.4.0",
+                "ts-api-utils": "^1.3.0"
+            }
+        },
+        "@typescript-eslint/parser": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.12.2.tgz",
+            "integrity": "sha512-MrvlXNfGPLH3Z+r7Tk+Z5moZAc0dzdVjTgUgwsdGweH7lydysQsnSww3nAmsq8blFuRD5VRlAr9YdEFw3e6PBw==",
+            "dev": true,
+            "requires": {
+                "@typescript-eslint/scope-manager": "8.12.2",
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/typescript-estree": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2",
+                "debug": "^4.3.4"
+            },
+            "dependencies": {
+                "debug": {
+                    "version": "4.3.7",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+                    "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+                    "dev": true,
+                    "requires": {
+                        "ms": "^2.1.3"
+                    }
+                }
+            }
+        },
+        "@typescript-eslint/scope-manager": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.12.2.tgz",
+            "integrity": "sha512-gPLpLtrj9aMHOvxJkSbDBmbRuYdtiEbnvO25bCMza3DhMjTQw0u7Y1M+YR5JPbMsXXnSPuCf5hfq0nEkQDL/JQ==",
+            "dev": true,
+            "requires": {
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2"
+            }
+        },
+        "@typescript-eslint/type-utils": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.12.2.tgz",
+            "integrity": "sha512-bwuU4TAogPI+1q/IJSKuD4shBLc/d2vGcRT588q+jzayQyjVK2X6v/fbR4InY2U2sgf8MEvVCqEWUzYzgBNcGQ==",
+            "dev": true,
+            "requires": {
+                "@typescript-eslint/typescript-estree": "8.12.2",
+                "@typescript-eslint/utils": "8.12.2",
+                "debug": "^4.3.4",
+                "ts-api-utils": "^1.3.0"
+            },
+            "dependencies": {
+                "debug": {
+                    "version": "4.3.7",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+                    "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+                    "dev": true,
+                    "requires": {
+                        "ms": "^2.1.3"
+                    }
+                }
+            }
+        },
+        "@typescript-eslint/types": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.12.2.tgz",
+            "integrity": "sha512-VwDwMF1SZ7wPBUZwmMdnDJ6sIFk4K4s+ALKLP6aIQsISkPv8jhiw65sAK6SuWODN/ix+m+HgbYDkH+zLjrzvOA==",
+            "dev": true
+        },
+        "@typescript-eslint/typescript-estree": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.12.2.tgz",
+            "integrity": "sha512-mME5MDwGe30Pq9zKPvyduyU86PH7aixwqYR2grTglAdB+AN8xXQ1vFGpYaUSJ5o5P/5znsSBeNcs5g5/2aQwow==",
+            "dev": true,
+            "requires": {
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/visitor-keys": "8.12.2",
+                "debug": "^4.3.4",
+                "fast-glob": "^3.3.2",
+                "is-glob": "^4.0.3",
+                "minimatch": "^9.0.4",
+                "semver": "^7.6.0",
+                "ts-api-utils": "^1.3.0"
+            },
+            "dependencies": {
+                "debug": {
+                    "version": "4.3.7",
+                    "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+                    "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+                    "dev": true,
+                    "requires": {
+                        "ms": "^2.1.3"
+                    }
+                },
+                "minimatch": {
+                    "version": "9.0.5",
+                    "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+                    "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+                    "dev": true,
+                    "requires": {
+                        "brace-expansion": "^2.0.1"
+                    }
+                },
+                "semver": {
+                    "version": "7.6.3",
+                    "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+                    "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+                    "dev": true
+                }
+            }
+        },
+        "@typescript-eslint/utils": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.12.2.tgz",
+            "integrity": "sha512-UTTuDIX3fkfAz6iSVa5rTuSfWIYZ6ATtEocQ/umkRSyC9O919lbZ8dcH7mysshrCdrAM03skJOEYaBugxN+M6A==",
+            "dev": true,
+            "requires": {
+                "@eslint-community/eslint-utils": "^4.4.0",
+                "@typescript-eslint/scope-manager": "8.12.2",
+                "@typescript-eslint/types": "8.12.2",
+                "@typescript-eslint/typescript-estree": "8.12.2"
+            }
+        },
+        "@typescript-eslint/visitor-keys": {
+            "version": "8.12.2",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.12.2.tgz",
+            "integrity": "sha512-PChz8UaKQAVNHghsHcPyx1OMHoFRUEA7rJSK/mDhdq85bk+PLsUHUBqTQTFt18VJZbmxBovM65fezlheQRsSDA==",
+            "dev": true,
+            "requires": {
+                "@typescript-eslint/types": "8.12.2",
+                "eslint-visitor-keys": "^3.4.3"
+            }
+        },
+        "@ungap/structured-clone": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+            "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+            "dev": true
+        },
         "acorn": {
-            "version": "8.8.2",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
-            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "version": "8.14.0",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
+            "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
             "dev": true
         },
         "acorn-jsx": {
@@ -6018,6 +6882,15 @@
             "dev": true,
             "requires": {}
         },
+        "acorn-walk": {
+            "version": "8.3.4",
+            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+            "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+            "dev": true,
+            "requires": {
+                "acorn": "^8.11.0"
+            }
+        },
         "aggregate-error": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -6085,6 +6958,12 @@
             "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
             "dev": true
         },
+        "arg": {
+            "version": "4.1.3",
+            "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+            "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+            "dev": true
+        },
         "argparse": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -6451,6 +7330,12 @@
             "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
             "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
         },
+        "create-require": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+            "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+            "dev": true
+        },
         "cron": {
             "version": "3.1.7",
             "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz",
@@ -6739,27 +7624,28 @@
             "dev": true
         },
         "eslint": {
-            "version": "8.37.0",
-            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.37.0.tgz",
-            "integrity": "sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==",
+            "version": "8.57.1",
+            "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
+            "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
             "dev": true,
             "requires": {
                 "@eslint-community/eslint-utils": "^4.2.0",
-                "@eslint-community/regexpp": "^4.4.0",
-                "@eslint/eslintrc": "^2.0.2",
-                "@eslint/js": "8.37.0",
-                "@humanwhocodes/config-array": "^0.11.8",
+                "@eslint-community/regexpp": "^4.6.1",
+                "@eslint/eslintrc": "^2.1.4",
+                "@eslint/js": "8.57.1",
+                "@humanwhocodes/config-array": "^0.13.0",
                 "@humanwhocodes/module-importer": "^1.0.1",
                 "@nodelib/fs.walk": "^1.2.8",
-                "ajv": "^6.10.0",
+                "@ungap/structured-clone": "^1.2.0",
+                "ajv": "^6.12.4",
                 "chalk": "^4.0.0",
                 "cross-spawn": "^7.0.2",
                 "debug": "^4.3.2",
                 "doctrine": "^3.0.0",
                 "escape-string-regexp": "^4.0.0",
-                "eslint-scope": "^7.1.1",
-                "eslint-visitor-keys": "^3.4.0",
-                "espree": "^9.5.1",
+                "eslint-scope": "^7.2.2",
+                "eslint-visitor-keys": "^3.4.3",
+                "espree": "^9.6.1",
                 "esquery": "^1.4.2",
                 "esutils": "^2.0.2",
                 "fast-deep-equal": "^3.1.3",
@@ -6767,22 +7653,19 @@
                 "find-up": "^5.0.0",
                 "glob-parent": "^6.0.2",
                 "globals": "^13.19.0",
-                "grapheme-splitter": "^1.0.4",
+                "graphemer": "^1.4.0",
                 "ignore": "^5.2.0",
-                "import-fresh": "^3.0.0",
                 "imurmurhash": "^0.1.4",
                 "is-glob": "^4.0.0",
                 "is-path-inside": "^3.0.3",
-                "js-sdsl": "^4.1.4",
                 "js-yaml": "^4.1.0",
                 "json-stable-stringify-without-jsonify": "^1.0.1",
                 "levn": "^0.4.1",
                 "lodash.merge": "^4.6.2",
                 "minimatch": "^3.1.2",
                 "natural-compare": "^1.4.0",
-                "optionator": "^0.9.1",
+                "optionator": "^0.9.3",
                 "strip-ansi": "^6.0.1",
-                "strip-json-comments": "^3.1.0",
                 "text-table": "^0.2.0"
             },
             "dependencies": {
@@ -6839,9 +7722,9 @@
             "requires": {}
         },
         "eslint-scope": {
-            "version": "7.1.1",
-            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
-            "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+            "version": "7.2.2",
+            "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+            "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
             "dev": true,
             "requires": {
                 "esrecurse": "^4.3.0",
@@ -6849,20 +7732,20 @@
             }
         },
         "eslint-visitor-keys": {
-            "version": "3.4.0",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz",
-            "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==",
+            "version": "3.4.3",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+            "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
             "dev": true
         },
         "espree": {
-            "version": "9.5.1",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz",
-            "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==",
+            "version": "9.6.1",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+            "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
             "dev": true,
             "requires": {
-                "acorn": "^8.8.0",
+                "acorn": "^8.9.0",
                 "acorn-jsx": "^5.3.2",
-                "eslint-visitor-keys": "^3.4.0"
+                "eslint-visitor-keys": "^3.4.1"
             }
         },
         "esprima": {
@@ -6921,6 +7804,19 @@
             "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
             "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
         },
+        "fast-glob": {
+            "version": "3.3.2",
+            "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+            "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+            "dev": true,
+            "requires": {
+                "@nodelib/fs.stat": "^2.0.2",
+                "@nodelib/fs.walk": "^1.2.3",
+                "glob-parent": "^5.1.2",
+                "merge2": "^1.3.0",
+                "micromatch": "^4.0.4"
+            }
+        },
         "fast-json-stable-stringify": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -7175,9 +8071,9 @@
             }
         },
         "globals": {
-            "version": "13.20.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz",
-            "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==",
+            "version": "13.24.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+            "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
             "dev": true,
             "requires": {
                 "type-fest": "^0.20.2"
@@ -7205,10 +8101,10 @@
             "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
             "dev": true
         },
-        "grapheme-splitter": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
-            "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+        "graphemer": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+            "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
             "dev": true
         },
         "har-schema": {
@@ -7313,9 +8209,9 @@
             }
         },
         "ignore": {
-            "version": "5.2.4",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
-            "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+            "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
             "dev": true
         },
         "import-fresh": {
@@ -7665,12 +8561,6 @@
                 "istanbul-lib-report": "^3.0.0"
             }
         },
-        "js-sdsl": {
-            "version": "4.4.0",
-            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz",
-            "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==",
-            "dev": true
-        },
         "js-tokens": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7809,6 +8699,12 @@
                 "semver": "^6.0.0"
             }
         },
+        "make-error": {
+            "version": "1.3.6",
+            "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+            "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+            "dev": true
+        },
         "md5": {
             "version": "2.3.0",
             "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
@@ -7820,6 +8716,22 @@
                 "is-buffer": "~1.1.6"
             }
         },
+        "merge2": {
+            "version": "1.4.1",
+            "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+            "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+            "dev": true
+        },
+        "micromatch": {
+            "version": "4.0.8",
+            "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+            "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+            "dev": true,
+            "requires": {
+                "braces": "^3.0.3",
+                "picomatch": "^2.3.1"
+            }
+        },
         "mime": {
             "version": "1.6.0",
             "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -8176,9 +9088,9 @@
             }
         },
         "optionator": {
-            "version": "0.9.1",
-            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
-            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "version": "0.9.4",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+            "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
             "dev": true,
             "requires": {
                 "deep-is": "^0.1.3",
@@ -8186,7 +9098,7 @@
                 "levn": "^0.4.1",
                 "prelude-ls": "^1.2.1",
                 "type-check": "^0.4.0",
-                "word-wrap": "^1.2.3"
+                "word-wrap": "^1.2.5"
             }
         },
         "p-limit": {
@@ -8841,6 +9753,42 @@
                 "url-parse": "^1.5.3"
             }
         },
+        "ts-api-utils": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz",
+            "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==",
+            "dev": true,
+            "requires": {}
+        },
+        "ts-node": {
+            "version": "10.9.2",
+            "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+            "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+            "dev": true,
+            "requires": {
+                "@cspotcode/source-map-support": "^0.8.0",
+                "@tsconfig/node10": "^1.0.7",
+                "@tsconfig/node12": "^1.0.7",
+                "@tsconfig/node14": "^1.0.0",
+                "@tsconfig/node16": "^1.0.2",
+                "acorn": "^8.4.1",
+                "acorn-walk": "^8.1.1",
+                "arg": "^4.1.0",
+                "create-require": "^1.1.0",
+                "diff": "^4.0.1",
+                "make-error": "^1.1.1",
+                "v8-compile-cache-lib": "^3.0.1",
+                "yn": "3.1.1"
+            },
+            "dependencies": {
+                "diff": {
+                    "version": "4.0.2",
+                    "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+                    "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+                    "dev": true
+                }
+            }
+        },
         "tunnel-agent": {
             "version": "0.6.0",
             "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -8894,6 +9842,12 @@
                 "is-typedarray": "^1.0.0"
             }
         },
+        "typescript": {
+            "version": "5.6.3",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+            "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+            "dev": true
+        },
         "unbox-primitive": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -8905,6 +9859,12 @@
                 "which-boxed-primitive": "^1.0.2"
             }
         },
+        "undici-types": {
+            "version": "6.19.8",
+            "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+            "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+            "dev": true
+        },
         "universalify": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
@@ -8947,6 +9907,12 @@
             "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
             "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
         },
+        "v8-compile-cache-lib": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+            "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+            "dev": true
+        },
         "verror": {
             "version": "1.10.0",
             "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
@@ -9095,6 +10061,12 @@
                 "is-plain-obj": "^2.1.0"
             }
         },
+        "yn": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+            "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+            "dev": true
+        },
         "yocto-queue": {
             "version": "0.1.0",
             "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/apps/ecash-herald/package.json b/apps/ecash-herald/package.json
--- a/apps/ecash-herald/package.json
+++ b/apps/ecash-herald/package.json
@@ -1,16 +1,16 @@
 {
     "name": "ecash-herald",
-    "version": "1.0.2",
+    "version": "2.0.0",
     "description": "A telegram bot to broadcast ecash chain events",
     "main": "index.js",
     "scripts": {
         "test": "mocha",
         "coverage": "nyc mocha",
+        "build": "tsc",
         "junit": "mocha test --reporter mocha-junit-reporter",
         "generateMock": "node scripts/generateMock",
         "getCoingeckoPrices": "node scripts/getCoingeckoPrices",
-        "sendMsgByBlock": "node scripts/sendMsgByBlock",
-        "getSlpTwoTokenInfo": "node scripts/getSlpTwoTokenInfo"
+        "sendMsgByBlock": "node scripts/sendMsgByBlock"
     },
     "keywords": [
         "ecash",
@@ -36,11 +36,18 @@
     },
     "devDependencies": {
         "@sinonjs/fake-timers": "^11.2.2",
+        "@types/mocha": "^10.0.9",
+        "@types/node-telegram-bot-api": "^0.64.7",
+        "@types/sinonjs__fake-timers": "^8.1.5",
+        "@typescript-eslint/eslint-plugin": "^8.12.2",
+        "@typescript-eslint/parser": "^8.12.2",
         "eslint": "^8.37.0",
         "eslint-plugin-header": "^3.1.1",
         "mocha": "^10.2.0",
         "mocha-junit-reporter": "^2.2.0",
         "mocha-suppress-logs": "^0.3.1",
-        "nyc": "^15.1.0"
+        "nyc": "^15.1.0",
+        "ts-node": "^10.9.2",
+        "typescript": "^5.6.3"
     }
 }
diff --git a/apps/ecash-herald/scripts/generateMock.js b/apps/ecash-herald/scripts/generateMock.ts
rename from apps/ecash-herald/scripts/generateMock.js
rename to apps/ecash-herald/scripts/generateMock.ts
--- a/apps/ecash-herald/scripts/generateMock.js
+++ b/apps/ecash-herald/scripts/generateMock.ts
@@ -2,39 +2,38 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const config = require('../config');
-const fs = require('fs');
-const path = require('path');
-const { ChronikClient } = require('chronik-client');
-const chronik = new ChronikClient(config.chronik);
-const { MockChronikClient } = require('../../../modules/mock-chronik-client');
+import config from '../config';
+import fs from 'fs';
+import path from 'path';
+import { ChronikClient, Tx } from 'chronik-client';
+import { MockChronikClient } from '../../../modules/mock-chronik-client';
+import { jsonReplacer, getCoingeckoApiUrl } from '../src/utils';
+import unrevivedBlockMocks from '../test/mocks/block';
+import { jsonReviver } from '../src/utils';
+import { handleBlockFinalized, StoredMock } from '../src/events';
+import { parseBlockTxs } from '../src/parse';
+import { sendBlockSummary } from '../src/telegram';
+import cashaddr from 'ecashaddrjs';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { caching } from 'cache-manager';
+import { MockTelegramBot } from '../test/mocks/telegramBotMock';
+import secrets from '../secrets';
+import TelegramBot from 'node-telegram-bot-api';
+
 const mockedChronik = new MockChronikClient();
-const { jsonReplacer, getCoingeckoApiUrl } = require('../src/utils');
-const unrevivedBlockMocks = require('../test/mocks/block');
-const { jsonReviver } = require('../src/utils');
-const blockMocks = JSON.parse(JSON.stringify(unrevivedBlockMocks), jsonReviver);
-const { handleBlockFinalized } = require('../src/events');
-const { parseBlockTxs } = require('../src/parse');
-const { sendBlockSummary } = require('../src/telegram');
-const cashaddr = require('ecashaddrjs');
-// Mock all price API calls
-const axios = require('axios');
-const MockAdapter = require('axios-mock-adapter');
-const { caching } = require('cache-manager');
-// Mock telegram bot
-const { MockTelegramBot } = require('../test/mocks/telegramBotMock');
+const chronik = new ChronikClient(config.chronik);
 const mockedTelegramBot = new MockTelegramBot();
-// Initialize telegram bot to send msgs to dev channel
-const secrets = require('../secrets');
-const TelegramBot = require('node-telegram-bot-api');
 const { dev } = secrets;
 const { botId, channelId } = dev.telegram;
-// Create a bot that uses 'polling' to fetch new updates
+
+const blockMocks = JSON.parse(JSON.stringify(unrevivedBlockMocks), jsonReviver);
+
+// Initialize telegram bot to send msgs to dev channel
 const telegramBotDev = new TelegramBot(botId, { polling: true });
 
 /**
- * generateMock.js
+ * generateMock
  *
  * This script takes an array of txids and builds a fake block with them
  * In this way we can still use ecash-herald's block-parsing functionality
@@ -98,13 +97,13 @@
 ];
 
 async function generateMock(
-    chronik,
-    mockedChronik,
-    telegramBot,
-    mockedTelegramBot,
-    channelId,
-    block,
-    txids,
+    chronik: ChronikClient,
+    mockedChronik: typeof MockChronikClient,
+    telegramBot: TelegramBot,
+    mockedTelegramBot: MockTelegramBot,
+    channelId: string,
+    block: StoredMock,
+    txids: string[],
 ) {
     const { outputScriptInfoMap, tokenInfoMap, coingeckoResponse } = block;
     // Get txids from your saved block
@@ -124,10 +123,10 @@
     let chronikTxidPromises = [];
     for (let i in newTxids) {
         chronikTxidPromises.push(
-            new Promise((resolve, reject) => {
+            new Promise<Tx>((resolve, reject) => {
                 chronik.tx(newTxids[i]).then(
                     result => {
-                        resolve(result);
+                        resolve(result as Tx);
                     },
                     err => {
                         reject(err);
@@ -136,7 +135,7 @@
             }),
         );
     }
-    let newChronikTxs;
+    let newChronikTxs: Tx[];
     try {
         newChronikTxs = await Promise.all(chronikTxidPromises);
     } catch (err) {
@@ -182,8 +181,9 @@
             cashaddr.getTypeAndHashFromOutputScript(outputScript);
         mockedChronik.setScript(type, hash);
 
-        if (outputScriptInfoMap.has(outputScript)) {
-            const { utxos } = outputScriptInfoMap.get(outputScript);
+        const outputScriptInfo = outputScriptInfoMap.get(outputScript);
+        if (typeof outputScriptInfo !== 'undefined') {
+            const { utxos } = outputScriptInfo;
             mockedChronik.setUtxos(type, hash, { outputScript, utxos });
         } else {
             // If you don't have a mock for this particular outputScript in block.js,
@@ -207,12 +207,12 @@
     // Generate app mocks using this block
     // TODO need to mock all the calls here
     // so need to manually build outputscriptinfomap, tokeninfomap
-    const CACHE_TTL = 2 * config.waitForFinalizationMsecs;
+    const CACHE_TTL = 2 * config.cacheTtlMsecs;
     const memoryCache = await caching('memory', {
         max: 100,
         ttl: CACHE_TTL,
     });
-    const returnedMocks = await handleBlockFinalized(
+    const returnedMocks = (await handleBlockFinalized(
         mockedChronik,
         mockedTelegramBot,
         channelId,
@@ -220,7 +220,7 @@
         MOCK_HEIGHT,
         memoryCache,
         true,
-    );
+    )) as StoredMock;
 
     // Save it to a file
     // Directory for mocks. Relative to /scripts, ../test/mocks/generated/
@@ -228,7 +228,7 @@
     //const mocksFileName = `uber_block_${Date.now()}.json`;
 
     const mocksDir = path.join(__dirname, '..', 'test', 'mocks');
-    const mocksFileName = 'block.js';
+    const mocksFileName = 'block.ts';
 
     // Create directory if it does not exist
     if (!fs.existsSync(mocksDir)) {
@@ -237,15 +237,16 @@
     // We want this string to appear in the generated blocks.js file,
     // but not in this file, as we want this file to show up in phab diffs
 
-    const mocksWrite = `// Copyright (c) 2023 The Bitcoin developers\n// Distributed under the MIT software license, see the accompanying\n// file COPYING or http://www.opensource.org/licenses/mit-license.php.\n\n'use strict'\n\nmodule.exports=${JSON.stringify(
+    const mocksWrite = `// Copyright (c) 2023 The Bitcoin developers\n// Distributed under the MIT software license, see the accompanying\n// file COPYING or http://www.opensource.org/licenses/mit-license.php.\n\nconst mockedBlock: any =${JSON.stringify(
         returnedMocks,
         jsonReplacer,
         2,
-    )}`;
+    )};\n\nexport default mockedBlock;\n`;
 
     fs.writeFileSync(`${mocksDir}/${mocksFileName}`, mocksWrite, 'utf-8');
 
     // Send msg(s) to Telegram
+
     const { blockSummaryTgMsgs, blockSummaryTgMsgsApiFailure } = returnedMocks;
 
     // Send msg with successful price API call
diff --git a/apps/ecash-herald/scripts/getCoingeckoPrices.js b/apps/ecash-herald/scripts/getCoingeckoPrices.ts
rename from apps/ecash-herald/scripts/getCoingeckoPrices.js
rename to apps/ecash-herald/scripts/getCoingeckoPrices.ts
--- a/apps/ecash-herald/scripts/getCoingeckoPrices.js
+++ b/apps/ecash-herald/scripts/getCoingeckoPrices.ts
@@ -2,9 +2,8 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const { getCoingeckoPrices, formatPrice } = require('../src/utils');
-const config = require('../config');
+import { getCoingeckoPrices, formatPrice } from '../src/utils';
+import config from '../config';
 
 const testedPriceObjects = [
     config.priceApi,
@@ -32,13 +31,19 @@
     },
 ];
 
-async function printGetPricesInfo(priceInfoObj) {
+async function printGetPricesInfo(priceInfoObj: any) {
     const { cryptos, fiat, precision } = priceInfoObj;
-    const { coingeckoPrices } = await getCoingeckoPrices(priceInfoObj);
+    const resp = await getCoingeckoPrices(priceInfoObj);
+    let coingeckoPrices;
+    if (resp !== false) {
+        coingeckoPrices = resp.coingeckoPrices;
+    } else {
+        return console.error(`Failed to fetch coingeckoPrices`);
+    }
 
     console.log(
         `Price info for ${cryptos
-            .map(crypto => {
+            .map((crypto: any) => {
                 return crypto.ticker;
             })
             .join(
diff --git a/apps/ecash-herald/scripts/getDailySummary.js b/apps/ecash-herald/scripts/getDailySummary.ts
rename from apps/ecash-herald/scripts/getDailySummary.js
rename to apps/ecash-herald/scripts/getDailySummary.ts
--- a/apps/ecash-herald/scripts/getDailySummary.js
+++ b/apps/ecash-herald/scripts/getDailySummary.ts
@@ -14,41 +14,32 @@
  * node scripts/getDailySummary 1729031373
  */
 
-'use strict';
-// Initialize chronik
-const config = require('../config');
-const { ChronikClient } = require('chronik-client');
-const axios = require('axios');
-const {
+import config from '../config';
+import { ChronikClient } from 'chronik-client';
+import axios from 'axios';
+import {
     getAllBlockTxs,
     getBlocksAgoFromChaintipByTimestamp,
     getTokenInfoMap,
-} = require('../src/chronik');
-const { summarizeTxHistory } = require('../src/parse');
-const { sendBlockSummary } = require('../src/telegram');
+} from '../src/chronik';
+import { summarizeTxHistory } from '../src/parse';
+import { sendBlockSummary } from '../src/telegram';
+import secrets from '../secrets';
+import TelegramBot from 'node-telegram-bot-api';
+
 // Initialize telegram bot to send msgs to dev channel
-const secrets = require('../secrets');
-const TelegramBot = require('node-telegram-bot-api');
 const { dev } = secrets;
 const { botId, channelId } = dev.telegram;
-// Create a bot that uses 'polling' to fetch new updates
 const telegramBotDev = new TelegramBot(botId, { polling: true });
 
-// Default to the commonly seen slp2 token
-const blockheight =
-    process.argv && typeof process.argv[2] !== 'undefined'
-        ? process.argv[2]
-        : false;
-
 const chronik = new ChronikClient(config.chronik);
 
 /**
  * Build a summary of eCash onchain activity over the last 24 hours
- * @param {number | undefined} timestamp unix timestamp in seconds
- * @param {object} telegramBot
- * @param {number} channelId
+ * @param telegramBot
+ * @param channelId
  */
-const getDailySummary = async (timestamp, telegramBot, channelId) => {
+const getDailySummary = async (telegramBot: TelegramBot, channelId: string) => {
     // Get price info for tg msg, if available
     let priceInfo;
     try {
@@ -61,10 +52,7 @@
         console.error(`Error getting daily summary price info`, err);
     }
 
-    if (timestamp === false) {
-        // If timestamp is not specified, use right now
-        timestamp = Math.floor(Date.now() / 1000);
-    }
+    const timestamp = Math.floor(Date.now() / 1000);
 
     console.log(`Sending daily summary thru ${new Date(timestamp * 1000)}`);
 
@@ -84,7 +72,7 @@
 
     const allBlockTxs = (await Promise.all(getAllBlockTxPromises)).flat();
 
-    const tokensToday = new Set();
+    const tokensToday: Set<string> = new Set();
     for (const tx of allBlockTxs) {
         const { tokenEntries } = tx;
         for (const tokenEntry of tokenEntries) {
@@ -126,4 +114,4 @@
     process.exit(0);
 };
 
-getDailySummary(blockheight, telegramBotDev, channelId);
+getDailySummary(telegramBotDev, channelId);
diff --git a/apps/ecash-herald/scripts/getSlpTwoTokenInfo.js b/apps/ecash-herald/scripts/getSlpTwoTokenInfo.js
deleted file mode 100644
--- a/apps/ecash-herald/scripts/getSlpTwoTokenInfo.js
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-
-// Initialize chronik
-const config = require('../config');
-const { ChronikClient } = require('chronik-client');
-const chronik = new ChronikClient(config.chronik);
-
-const { consume, consumeNextPush, swapEndianness } = require('ecash-script');
-const opReturn = require('../constants/op_return');
-
-// Default to the commonly seen slp2 token
-let tokenId =
-    'cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145';
-
-// Look for blockheight specified from command line
-if (process.argv && typeof process.argv[2] !== 'undefined') {
-    // user input if available, commas removed
-    tokenId = process.argv[2];
-}
-
-const getSlpTwoTokenInfo = async (chronik, tokenId) => {
-    let txInfo;
-    try {
-        txInfo = await chronik.tx(tokenId);
-    } catch (err) {
-        console.log(
-            '\x1b[31m%s\x1b[0m',
-            `Error in chronik.tx(${tokenId})`,
-            err,
-        );
-        // Exit in error condition
-        process.exit(1);
-    }
-    // Because slp 2 tokens are not yet indexed, you will need to parse the EMPP OP_RETURN
-    const { outputs } = txInfo;
-    for (let i = 0; i < outputs.length; i += 1) {
-        // For now, assume the first OP_RETURN field you find is what you are looking for
-        // This is a manual script and does not need to handle edge cases like multiple OP_RETURNS in an slp2 genesis tx
-        const { outputScript } = outputs[i];
-        const testString = `${opReturn.opReturnPrefix}${opReturn.opReserved}`;
-        let stack;
-        if (outputScript.startsWith(testString)) {
-            const emppPush = consumeNextPush({
-                remainingHex: outputScript.slice(testString.length),
-            }).data;
-            stack = { remainingHex: emppPush };
-        } else {
-            // If this is not the outputScript you are looking for
-            // keep looking
-            continue;
-        }
-        // Parse for slp 2 genesis, per spec at
-        // https://ecashbuilders.notion.site/SLPv2-a862a4130877448387373b9e6a93dd97
-
-        console.log(`stack`, stack);
-
-        const isSlpTwo =
-            consume(stack, opReturn.knownApps.slp2.prefix.length / 2) ===
-            opReturn.knownApps.slp2.prefix;
-        if (!isSlpTwo) {
-            console.log(
-                '\x1b[31m%s\x1b[0m',
-                `Error: Protocol identifier is not SLP2`,
-            );
-            // Exit in error condition
-            process.exit(1);
-        }
-        const tokenType = consume(stack, 1);
-        if (tokenType !== '00') {
-            console.log('\x1b[31m%s\x1b[0m', `Error: Unknown SLP2 token type`);
-            // Exit in error condition
-            process.exit(1);
-        }
-        const txTypeBytes = parseInt(consume(stack, 1), 16);
-        const txType = Buffer.from(consume(stack, txTypeBytes), 'hex').toString(
-            'utf8',
-        );
-        if (txType !== 'GENESIS') {
-            console.log(
-                '\x1b[31m%s\x1b[0m',
-                `Error: SLP2 tx is not an SLP2 Genesis tx`,
-            );
-            // Exit in error condition
-            process.exit(1);
-        }
-        const tickerBytes = parseInt(consume(stack, 1), 16);
-        const ticker = Buffer.from(consume(stack, tickerBytes), 'hex').toString(
-            'utf8',
-        );
-        const nameBytes = parseInt(consume(stack, 1), 16);
-        const name = Buffer.from(consume(stack, nameBytes), 'hex').toString(
-            'utf8',
-        );
-        const urlBytes = parseInt(consume(stack, 1), 16);
-        const url = Buffer.from(consume(stack, urlBytes), 'hex').toString(
-            'utf8',
-        );
-        const dataBytes = parseInt(consume(stack, 1), 16);
-        const data = Buffer.from(consume(stack, dataBytes), 'hex').toString(
-            'utf8',
-        );
-        const authPubKeyBytes = parseInt(consume(stack, 1), 16);
-        const authPubKey = consume(stack, authPubKeyBytes);
-
-        const decimals = parseInt(consume(stack, 1), 16);
-        const numMintAmounts = parseInt(consume(stack, 1), 16);
-        let mintAmount = 0;
-        for (let i = 0; i < numMintAmounts; i += 1) {
-            mintAmount += parseInt(swapEndianness(consume(stack, 6)));
-        }
-        const numMintBatons = parseInt(consume(stack, 1), 16);
-
-        console.log(`ticker`, ticker);
-        console.log(`name`, name);
-        console.log(`url`, url);
-        console.log(`dataBytes`, dataBytes);
-        console.log(`data`, data);
-        console.log(`authPubKey`, authPubKey);
-        console.log(`decimals`, decimals);
-        console.log(`numMintAmounts`, numMintAmounts);
-        console.log(`mintAmount`, mintAmount);
-        console.log(`numMintBatons`, numMintBatons);
-    }
-};
-
-getSlpTwoTokenInfo(chronik, tokenId);
diff --git a/apps/ecash-herald/scripts/sendMsgByBlock.js b/apps/ecash-herald/scripts/sendMsgByBlock.ts
rename from apps/ecash-herald/scripts/sendMsgByBlock.js
rename to apps/ecash-herald/scripts/sendMsgByBlock.ts
--- a/apps/ecash-herald/scripts/sendMsgByBlock.js
+++ b/apps/ecash-herald/scripts/sendMsgByBlock.ts
@@ -2,11 +2,8 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const { caching } = require('cache-manager');
-
 /**
- * sendMsgByBlockheight.js
+ * sendMsgByBlockheight.ts
  *
  * A script to allow developer to generate and broadcast an ecash-herald message
  * by blockheight
@@ -25,10 +22,15 @@
  * node scripts/sendMsgByBlock.js 700000
  */
 
-// App functions
-const { handleBlockFinalized } = require('../src/events');
-const { sendBlockSummary } = require('../src/telegram');
-const { getCoingeckoApiUrl } = require('../src/utils');
+import { handleBlockFinalized } from '../src/events';
+import { sendBlockSummary } from '../src/telegram';
+import { getCoingeckoApiUrl } from '../src/utils';
+import config from '../config';
+import { ChronikClient } from 'chronik-client';
+import secrets from '../secrets';
+import TelegramBot from 'node-telegram-bot-api';
+import { caching } from 'cache-manager';
+import { StoredMock } from '../src/events';
 
 // Default to the genesis block
 let height = 0;
@@ -47,26 +49,23 @@
     }
 }
 
-// Initialize chronik
-const config = require('../config');
-const { ChronikClient } = require('chronik-client');
 const chronik = new ChronikClient(config.chronik);
-
-// Initialize telegram bot to send msgs to dev channel
-const secrets = require('../secrets');
-const TelegramBot = require('node-telegram-bot-api');
 const { dev } = secrets;
 const { botId, channelId } = dev.telegram;
-// Create a bot that uses 'polling' to fetch new updates
 const telegramBotDev = new TelegramBot(botId, { polling: true });
 
 // Mock price API call to prevent rate limiting during testing
 const axios = require('axios');
 const MockAdapter = require('axios-mock-adapter');
 
-async function sendMsgByBlock(chronik, telegramBot, channelId, height) {
+async function sendMsgByBlock(
+    chronik: ChronikClient,
+    telegramBot: TelegramBot,
+    channelId: string,
+    height: number,
+) {
     // Need cache to pass to function
-    const CACHE_TTL = 2 * config.waitForFinalizationMsecs;
+    const CACHE_TTL = 2 * config.cacheTtlMsecs;
     const memoryCache = await caching('memory', {
         max: 100,
         ttl: CACHE_TTL,
@@ -89,7 +88,7 @@
         hash = block.blockInfo.hash;
     }
 
-    const returnedMocks = await handleBlockFinalized(
+    const returnedMocks = (await handleBlockFinalized(
         chronik,
         telegramBot,
         channelId,
@@ -97,7 +96,7 @@
         height,
         memoryCache,
         true,
-    );
+    )) as StoredMock;
 
     const { blockSummaryTgMsgs, blockSummaryTgMsgsApiFailure } = returnedMocks;
 
diff --git a/apps/ecash-herald/secrets.sample.js b/apps/ecash-herald/secrets.sample.ts
rename from apps/ecash-herald/secrets.sample.js
rename to apps/ecash-herald/secrets.sample.ts
--- a/apps/ecash-herald/secrets.sample.js
+++ b/apps/ecash-herald/secrets.sample.ts
@@ -2,8 +2,18 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-module.exports = {
+interface TelegramSettings {
+    botId: string;
+    channelId: string;
+    dailyChannelId: string;
+}
+
+interface Secrets {
+    dev: { telegram: TelegramSettings };
+    prod: { telegram: TelegramSettings };
+}
+
+const secrets: Secrets = {
     dev: {
         telegram: {
             botId: 'botIdFromTelegramBotfather',
@@ -19,3 +29,5 @@
         },
     },
 };
+
+export default Secrets;
diff --git a/apps/ecash-herald/src/chronik.js b/apps/ecash-herald/src/chronik.js
deleted file mode 100644
--- a/apps/ecash-herald/src/chronik.js
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-const { getEmojiFromBalanceSats } = require('./utils');
-const cashaddr = require('ecashaddrjs');
-
-// Max txs we can get in one request
-const CHRONIK_MAX_PAGESIZE = 200;
-
-module.exports = {
-    getTokenInfoMap: async function (chronik, tokenIdSet) {
-        let tokenInfoMap = new Map();
-        const tokenInfoPromises = [];
-        tokenIdSet.forEach(tokenId => {
-            tokenInfoPromises.push(
-                new Promise((resolve, reject) => {
-                    chronik.token(tokenId).then(
-                        response => {
-                            // Note: txDetails.slpTxData.genesisInfo only exists for token genesis txs
-                            try {
-                                const genesisInfo = response.genesisInfo;
-                                tokenInfoMap.set(tokenId, genesisInfo);
-                                resolve(true);
-                            } catch (err) {
-                                console.log(
-                                    `Error getting genesis info for ${tokenId}`,
-                                    err,
-                                );
-                                reject(err);
-                            }
-                        },
-                        err => {
-                            reject(err);
-                        },
-                    );
-                }),
-            );
-        });
-
-        try {
-            await Promise.all(tokenInfoPromises);
-        } catch (err) {
-            console.log(`Error in await Promise.all(tokenInfoPromises)`, err);
-            // Print all tokenIds in event of error
-            // Note: any 1 promise failing in Promise.all() will hit this
-            // catch block
-            console.log(`tokenIdSet:`);
-            tokenIdSet.forEach(tokenId => {
-                console.log(tokenId);
-            });
-            return false;
-        }
-        return tokenInfoMap;
-    },
-    /**
-     * Build a reference map of outputScripts and their balance in satoshis
-     * @param {object} chronik
-     * @param {set} outputScripts
-     * @returns {map} addressInfoMap, a map with key = address, value = {balanceSats, emoji, utxos}
-     */
-    getOutputscriptInfoMap: async function (chronik, outputScripts) {
-        let outputScriptInfoMap = new Map();
-        const outputScriptInfoPromises = [];
-
-        // For each outputScript, create a promise to get its balance and add
-        // info related to this balance to outputScriptInfoMap
-        outputScripts.forEach(outputScript => {
-            // Decode output script
-            const { type, hash } =
-                cashaddr.getTypeAndHashFromOutputScript(outputScript);
-            outputScriptInfoPromises.push(
-                new Promise((resolve, reject) => {
-                    chronik
-                        .script(type, hash)
-                        .utxos()
-                        .then(
-                            response => {
-                                // If this address has no utxos, then utxos.length is 0
-                                // If this address has utxos, then utxos = [{utxos: []}]
-                                const balanceSats =
-                                    response.utxos.length === 0
-                                        ? 0
-                                        : response.utxos
-                                              .map(utxo => utxo.value)
-                                              .reduce(
-                                                  (prev, curr) => prev + curr,
-                                                  0,
-                                              );
-
-                                // Set the map outputScript => emoji
-                                outputScriptInfoMap.set(outputScript, {
-                                    emoji: getEmojiFromBalanceSats(balanceSats),
-                                    balanceSats,
-                                    utxos: response.utxos,
-                                });
-                                resolve(true);
-                            },
-                            err => {
-                                reject(err);
-                            },
-                        );
-                }),
-            );
-        });
-        try {
-            await Promise.all(outputScriptInfoPromises);
-        } catch (err) {
-            console.log(
-                `Error in await Promise.all(outputScriptInfoPromises)`,
-                err,
-            );
-            // Print all outputScripts in event of error
-            // Note: any 1 promise failing in Promise.all() will hit this
-            // catch block
-            console.log(`outputScripts:`);
-            outputScripts.forEach(outputScript => {
-                console.log(outputScript);
-            });
-            return false;
-        }
-        return outputScriptInfoMap;
-    },
-    /**
-     * Get all txs in a block
-     * Txs are paginated so this may require more than one API call
-     * @param {ChronikClient} chronik
-     * @param {number} blockHeight
-     * @throws {err} on chronik error
-     * @returns {Tx_InNode[]}
-     */
-    getAllBlockTxs: async function (
-        chronik,
-        blockHeight,
-        pageSize = CHRONIK_MAX_PAGESIZE,
-    ) {
-        const firstPage = await chronik.blockTxs(blockHeight, 0, pageSize);
-        const { txs, numPages } = firstPage;
-
-        if (numPages === 1) {
-            return txs;
-        }
-
-        const remainingPagesPromises = [];
-
-        // Start with i=1 as you already have the first page of txs, which corresponds with pagenum = 0
-        for (let i = 1; i < numPages; i += 1) {
-            remainingPagesPromises.push(
-                new Promise((resolve, reject) => {
-                    chronik.blockTxs(blockHeight, i, pageSize).then(
-                        result => {
-                            resolve(result.txs);
-                        },
-                        err => {
-                            reject(err);
-                        },
-                    );
-                }),
-            );
-        }
-        const remainingTxs = await Promise.all(remainingPagesPromises);
-
-        // Combine all txs into an array
-        return txs.concat(remainingTxs.flat());
-    },
-    /**
-     * Get the start and end blockheights that will include all txs within a specified time period
-     * Note: This function only works for time intervals relative to "right now"
-     * We always return chaintip as the end height
-     * @param {ChronikClient} chronik
-     * @param {number} now unix timestamp in seconds
-     * @param {number} secondsAgo how far back we are interested in getting blocks
-     */
-    getBlocksAgoFromChaintipByTimestamp: async function (
-        chronik,
-        now,
-        secondsAgo,
-    ) {
-        // Get the chaintip
-        const chaintip = (await chronik.blockchainInfo()).tipHeight;
-
-        // Make an educated guess about how many blocks ago the first block we want should be
-        // = 10 minutes per block * 60 seconds per minute
-        const SECONDS_PER_BLOCK = 600;
-        const guessedBlocksAgo = Math.floor(secondsAgo / SECONDS_PER_BLOCK);
-        const guessedBlockheight = chaintip - guessedBlocksAgo;
-
-        // Get the block from blocksAgo and check its timestamp
-        const guessedBlock = (await chronik.block(guessedBlockheight))
-            .blockInfo;
-
-        let guessedBlockTimestampDelta = now - guessedBlock.timestamp;
-
-        // We won't keep guessing forever
-        const ADDITIONAL_BLOCKS_TO_GUESS = 200;
-
-        let startBlockheight;
-        if (guessedBlockTimestampDelta > secondsAgo) {
-            // If the guessed block was further back in time than desired secondsAgo
-            // Then we need to guess a higher block
-            for (
-                let i = guessedBlockheight + 1;
-                i <= guessedBlockheight + ADDITIONAL_BLOCKS_TO_GUESS;
-                i += 1
-            ) {
-                const guessedBlock = (await chronik.block(i)).blockInfo;
-                const thisBlockTimestampDelta = now - guessedBlock.timestamp;
-                if (thisBlockTimestampDelta <= secondsAgo) {
-                    startBlockheight = i;
-                    break;
-                }
-            }
-        } else {
-            // We might already be looking at the right block
-            // But mb we some previous blocks are also in this acceptable window
-            // If the guessed block was NOT further back in time than desired secondsAgo
-            // Then we need to guess a LOWER block
-            for (
-                let i = guessedBlockheight - 1;
-                i >= guessedBlockheight - ADDITIONAL_BLOCKS_TO_GUESS;
-                i -= 1
-            ) {
-                const guessedBlock = (await chronik.block(i)).blockInfo;
-                guessedBlockTimestampDelta = now - guessedBlock.timestamp;
-                if (guessedBlockTimestampDelta > secondsAgo) {
-                    // We keep looking for blocks until we find one that is "too old"
-                    // Then we take the immediately newer block
-                    startBlockheight = i + 1;
-                    break;
-                }
-            }
-        }
-
-        if (typeof startBlockheight === 'undefined') {
-            console.log(
-                `Did not find startBlockheight in ${ADDITIONAL_BLOCKS_TO_GUESS} blocks`,
-            );
-            console.log(`Chaintip: ${chaintip}`);
-            console.log(`guessedBlockheight: ${guessedBlockheight}`);
-            console.log(
-                `guessedBlockTimestampDelta: ${guessedBlockTimestampDelta}`,
-            );
-            throw new Error(
-                `Start block more than ${ADDITIONAL_BLOCKS_TO_GUESS} off our original guess`,
-            );
-        }
-
-        return { chaintip, startBlockheight };
-    },
-};
diff --git a/apps/ecash-herald/src/chronik.ts b/apps/ecash-herald/src/chronik.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/src/chronik.ts
@@ -0,0 +1,266 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import {
+    ChronikClient,
+    ScriptType,
+    Tx,
+    GenesisInfo,
+    Utxo,
+} from 'chronik-client';
+import { getEmojiFromBalanceSats } from './utils';
+import cashaddr from 'ecashaddrjs';
+
+// Max txs we can get in one request
+const CHRONIK_MAX_PAGESIZE = 200;
+
+export type TokenInfoMap = Map<string, GenesisInfo>;
+export const getTokenInfoMap = async (
+    chronik: ChronikClient,
+    tokenIdSet: Set<string>,
+) => {
+    let tokenInfoMap: TokenInfoMap = new Map();
+    const tokenInfoPromises: Promise<void>[] = [];
+    tokenIdSet.forEach(tokenId => {
+        tokenInfoPromises.push(
+            new Promise((resolve, reject) => {
+                chronik.token(tokenId).then(
+                    response => {
+                        // Note: txDetails.slpTxData.genesisInfo only exists for token genesis txs
+                        try {
+                            const genesisInfo = response.genesisInfo;
+                            tokenInfoMap.set(tokenId, genesisInfo);
+                            resolve();
+                        } catch (err) {
+                            console.log(
+                                `Error getting genesis info for ${tokenId}`,
+                                err,
+                            );
+                            reject(err);
+                        }
+                    },
+                    err => {
+                        reject(err);
+                    },
+                );
+            }),
+        );
+    });
+
+    try {
+        await Promise.all(tokenInfoPromises);
+    } catch (err) {
+        console.log(`Error in await Promise.all(tokenInfoPromises)`, err);
+        // Print all tokenIds in event of error
+        // Note: any 1 promise failing in Promise.all() will hit this
+        // catch block
+        console.log(`tokenIdSet:`);
+        tokenIdSet.forEach(tokenId => {
+            console.log(tokenId);
+        });
+        return false;
+    }
+    return tokenInfoMap;
+};
+
+export interface OutputscriptInfo {
+    emoji: string;
+    balanceSats: number;
+    utxos: Utxo[];
+}
+/**
+ * Build a reference map of outputScripts and their balance in satoshis
+ * @param {object} chronik
+ * @param {set} outputScripts
+ * @returns {map} addressInfoMap, a map with key = address, value = {balanceSats, emoji, utxos}
+ */
+export const getOutputscriptInfoMap = async (
+    chronik: ChronikClient,
+    outputScripts: Set<string>,
+): Promise<false | Map<string, OutputscriptInfo>> => {
+    let outputScriptInfoMap = new Map();
+    const outputScriptInfoPromises: Promise<void>[] = [];
+
+    // For each outputScript, create a promise to get its balance and add
+    // info related to this balance to outputScriptInfoMap
+    outputScripts.forEach(outputScript => {
+        // Decode output script
+        const { type, hash } =
+            cashaddr.getTypeAndHashFromOutputScript(outputScript);
+        outputScriptInfoPromises.push(
+            new Promise((resolve, reject) => {
+                chronik
+                    .script(type as ScriptType, hash)
+                    .utxos()
+                    .then(
+                        response => {
+                            // If this address has no utxos, then utxos.length is 0
+                            // If this address has utxos, then utxos = [{utxos: []}]
+                            const balanceSats =
+                                response.utxos.length === 0
+                                    ? 0
+                                    : response.utxos
+                                          .map(utxo => utxo.value)
+                                          .reduce(
+                                              (prev, curr) => prev + curr,
+                                              0,
+                                          );
+
+                            // Set the map outputScript => emoji
+                            outputScriptInfoMap.set(outputScript, {
+                                emoji: getEmojiFromBalanceSats(balanceSats),
+                                balanceSats,
+                                utxos: response.utxos,
+                            });
+                            resolve();
+                        },
+                        err => {
+                            reject(err);
+                        },
+                    );
+            }),
+        );
+    });
+    try {
+        await Promise.all(outputScriptInfoPromises);
+    } catch (err) {
+        console.log(
+            `Error in await Promise.all(outputScriptInfoPromises)`,
+            err,
+        );
+        // Print all outputScripts in event of error
+        // Note: any 1 promise failing in Promise.all() will hit this
+        // catch block
+        console.log(`outputScripts:`);
+        outputScripts.forEach(outputScript => {
+            console.log(outputScript);
+        });
+        return false;
+    }
+    return outputScriptInfoMap;
+};
+/**
+ * Get all txs in a block
+ * Txs are paginated so this may require more than one API call
+ * @param chronik
+ * @param blockHeight
+ * @throws on chronik error
+ */
+export const getAllBlockTxs = async (
+    chronik: ChronikClient,
+    blockHeight: number,
+    pageSize = CHRONIK_MAX_PAGESIZE,
+): Promise<Tx[]> => {
+    const firstPage = await chronik.blockTxs(blockHeight, 0, pageSize);
+    const { txs, numPages } = firstPage;
+
+    if (numPages === 1) {
+        return txs;
+    }
+
+    const remainingPagesPromises: Promise<Tx[]>[] = [];
+
+    // Start with i=1 as you already have the first page of txs, which corresponds with pagenum = 0
+    for (let i = 1; i < numPages; i += 1) {
+        remainingPagesPromises.push(
+            new Promise((resolve, reject) => {
+                chronik.blockTxs(blockHeight, i, pageSize).then(
+                    result => {
+                        resolve(result.txs);
+                    },
+                    err => {
+                        reject(err);
+                    },
+                );
+            }),
+        );
+    }
+    const remainingTxs = await Promise.all(remainingPagesPromises);
+
+    // Combine all txs into an array
+    return txs.concat(remainingTxs.flat());
+};
+/**
+ * Get the start and end blockheights that will include all txs within a specified time period
+ * Note: This function only works for time intervals relative to "right now"
+ * We always return chaintip as the end height
+ * @param  chronik
+ * @param now unix timestamp in seconds
+ * @param  secondsAgo how far back we are interested in getting blocks
+ */
+export const getBlocksAgoFromChaintipByTimestamp = async (
+    chronik: ChronikClient,
+    now: number,
+    secondsAgo: number,
+): Promise<{ chaintip: number; startBlockheight: number }> => {
+    // Get the chaintip
+    const chaintip = (await chronik.blockchainInfo()).tipHeight;
+
+    // Make an educated guess about how many blocks ago the first block we want should be
+    // = 10 minutes per block * 60 seconds per minute
+    const SECONDS_PER_BLOCK = 600;
+    const guessedBlocksAgo = Math.floor(secondsAgo / SECONDS_PER_BLOCK);
+    const guessedBlockheight = chaintip - guessedBlocksAgo;
+
+    // Get the block from blocksAgo and check its timestamp
+    const guessedBlock = (await chronik.block(guessedBlockheight)).blockInfo;
+
+    let guessedBlockTimestampDelta = now - guessedBlock.timestamp;
+
+    // We won't keep guessing forever
+    const ADDITIONAL_BLOCKS_TO_GUESS = 200;
+
+    let startBlockheight;
+    if (guessedBlockTimestampDelta > secondsAgo) {
+        // If the guessed block was further back in time than desired secondsAgo
+        // Then we need to guess a higher block
+        for (
+            let i = guessedBlockheight + 1;
+            i <= guessedBlockheight + ADDITIONAL_BLOCKS_TO_GUESS;
+            i += 1
+        ) {
+            const guessedBlock = (await chronik.block(i)).blockInfo;
+            const thisBlockTimestampDelta = now - guessedBlock.timestamp;
+            if (thisBlockTimestampDelta <= secondsAgo) {
+                startBlockheight = i;
+                break;
+            }
+        }
+    } else {
+        // We might already be looking at the right block
+        // But mb we some previous blocks are also in this acceptable window
+        // If the guessed block was NOT further back in time than desired secondsAgo
+        // Then we need to guess a LOWER block
+        for (
+            let i = guessedBlockheight - 1;
+            i >= guessedBlockheight - ADDITIONAL_BLOCKS_TO_GUESS;
+            i -= 1
+        ) {
+            const guessedBlock = (await chronik.block(i)).blockInfo;
+            guessedBlockTimestampDelta = now - guessedBlock.timestamp;
+            if (guessedBlockTimestampDelta > secondsAgo) {
+                // We keep looking for blocks until we find one that is "too old"
+                // Then we take the immediately newer block
+                startBlockheight = i + 1;
+                break;
+            }
+        }
+    }
+
+    if (typeof startBlockheight === 'undefined') {
+        console.log(
+            `Did not find startBlockheight in ${ADDITIONAL_BLOCKS_TO_GUESS} blocks`,
+        );
+        console.log(`Chaintip: ${chaintip}`);
+        console.log(`guessedBlockheight: ${guessedBlockheight}`);
+        console.log(
+            `guessedBlockTimestampDelta: ${guessedBlockTimestampDelta}`,
+        );
+        throw new Error(
+            `Start block more than ${ADDITIONAL_BLOCKS_TO_GUESS} off our original guess`,
+        );
+    }
+
+    return { chaintip, startBlockheight };
+};
diff --git a/apps/ecash-herald/src/chronikWsHandler.js b/apps/ecash-herald/src/chronikWsHandler.js
deleted file mode 100644
--- a/apps/ecash-herald/src/chronikWsHandler.js
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-const { handleBlockFinalized, handleBlockInvalidated } = require('./events');
-
-module.exports = {
-    initializeWebsocket: async function (
-        chronik,
-        telegramBot,
-        channelId,
-        memoryCache,
-    ) {
-        // Subscribe to chronik websocket
-        const ws = chronik.ws({
-            onMessage: async msg => {
-                await module.exports.parseWebsocketMessage(
-                    chronik,
-                    msg,
-                    telegramBot,
-                    channelId,
-                    memoryCache,
-                );
-            },
-        });
-        // Wait for WS to be connected:
-        await ws.waitForOpen();
-        console.log(`Listening for chronik block msgs`);
-        // Subscribe to blocks
-        ws.subscribeToBlocks();
-        return ws;
-    },
-    parseWebsocketMessage: async function (
-        chronik,
-        wsMsg,
-        telegramBot,
-        channelId,
-        memoryCache,
-    ) {
-        // Get height and msg type
-        // Note 1: herald only subscribes to blocks, so only MsgBlockClient is expected here
-        // Note 2: blockTimestamp and coinbaseData might be undefined, they are
-        //         introduced in chronik v0.30.0 and client version 1.3.0
-        const {
-            msgType,
-            blockHeight,
-            blockHash,
-            blockTimestamp,
-            coinbaseData,
-        } = wsMsg;
-
-        switch (msgType) {
-            case 'BLK_FINALIZED': {
-                return handleBlockFinalized(
-                    chronik,
-                    telegramBot,
-                    channelId,
-                    blockHash,
-                    blockHeight,
-                    memoryCache,
-                );
-            }
-            case 'BLK_INVALIDATED': {
-                return handleBlockInvalidated(
-                    chronik,
-                    telegramBot,
-                    channelId,
-                    blockHash,
-                    blockHeight,
-                    blockTimestamp,
-                    coinbaseData,
-                    memoryCache,
-                );
-            }
-            default:
-                // Do nothing for other events
-                return false;
-        }
-    },
-};
diff --git a/apps/ecash-herald/src/chronikWsHandler.ts b/apps/ecash-herald/src/chronikWsHandler.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/src/chronikWsHandler.ts
@@ -0,0 +1,86 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+import { ChronikClient, WsEndpoint, WsMsgClient } from 'chronik-client';
+import { handleBlockFinalized, handleBlockInvalidated } from './events';
+import TelegramBot from 'node-telegram-bot-api';
+import { MemoryCache } from 'cache-manager';
+import { MockTelegramBot } from '../test/mocks/telegramBotMock';
+
+export const initializeWebsocket = async (
+    chronik: ChronikClient,
+    telegramBot: TelegramBot | MockTelegramBot,
+    channelId: string,
+    memoryCache: MemoryCache,
+): Promise<WsEndpoint> => {
+    // Subscribe to chronik websocket
+    const ws = chronik.ws({
+        onMessage: async msg => {
+            await parseWebsocketMessage(
+                chronik,
+                msg,
+                telegramBot,
+                channelId,
+                memoryCache,
+            );
+        },
+    });
+    // Wait for WS to be connected:
+    await ws.waitForOpen();
+    console.log(`Listening for chronik block msgs`);
+    // Subscribe to blocks
+    ws.subscribeToBlocks();
+    return ws;
+};
+
+export const parseWebsocketMessage = async (
+    chronik: ChronikClient,
+    wsMsg: WsMsgClient,
+    telegramBot: TelegramBot | MockTelegramBot,
+    channelId: string,
+    memoryCache: MemoryCache,
+) => {
+    // Get height and msg type
+    // Note 1: herald only subscribes to blocks, so only MsgBlockClient is expected here
+    // Note 2: blockTimestamp and coinbaseData might be undefined, they are
+    //         introduced in chronik v0.30.0 and client version 1.3.0
+
+    const { type } = wsMsg;
+    if (type === 'Error') {
+        // Do nothing on ws error msgs
+        return false;
+    }
+    const { msgType } = wsMsg;
+
+    switch (msgType) {
+        case 'BLK_FINALIZED': {
+            const { blockHeight, blockHash } = wsMsg;
+            return handleBlockFinalized(
+                chronik,
+                telegramBot,
+                channelId,
+                blockHash,
+                blockHeight,
+                memoryCache,
+            );
+        }
+        case 'BLK_INVALIDATED': {
+            // coinbaseData is defined for BLK_INVALIDATED
+            const { blockHeight, blockHash, blockTimestamp, coinbaseData } =
+                wsMsg;
+            return handleBlockInvalidated(
+                chronik,
+                telegramBot,
+                channelId,
+                blockHash,
+                blockHeight,
+                blockTimestamp,
+                coinbaseData!,
+                memoryCache,
+            );
+        }
+        default:
+            // Do nothing for other events
+            return false;
+    }
+};
diff --git a/apps/ecash-herald/src/events.js b/apps/ecash-herald/src/events.js
deleted file mode 100644
--- a/apps/ecash-herald/src/events.js
+++ /dev/null
@@ -1,299 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-const config = require('../config');
-const axios = require('axios');
-const cashaddr = require('ecashaddrjs');
-const {
-    parseBlockTxs,
-    getBlockTgMessage,
-    getMinerFromCoinbaseTx,
-    getStakerFromCoinbaseTx,
-    guessRejectReason,
-    summarizeTxHistory,
-} = require('./parse');
-const {
-    getCoingeckoPrices,
-    jsonReviver,
-    getNextStakingReward,
-} = require('./utils');
-const { sendBlockSummary } = require('./telegram');
-const {
-    getTokenInfoMap,
-    getOutputscriptInfoMap,
-    getAllBlockTxs,
-    getBlocksAgoFromChaintipByTimestamp,
-} = require('./chronik');
-const knownMinersJson = require('../constants/miners');
-
-const miners = JSON.parse(JSON.stringify(knownMinersJson), jsonReviver);
-
-module.exports = {
-    /**
-     * Callback function for a new finalized block on the eCash blockchain
-     * Summarize on-chain activity in this block
-     * @param {ChronikClient} chronik
-     * @param {object} telegramBot A connected telegramBot instance
-     * @param {number} channelId The channel ID where the telegram msg(s) will be sent
-     * @param {number} height blockheight
-     * @param {boolean} returnMocks If true, return mocks for unit tests
-     * @param {object} memoryCache
-     */
-    handleBlockFinalized: async function (
-        chronik,
-        telegramBot,
-        channelId,
-        blockHash,
-        blockHeight,
-        memoryCache,
-        returnMocks = false,
-    ) {
-        // Get block txs
-        // TODO blockTxs are paginated, need a function to get them all
-        let blockTxs;
-        try {
-            blockTxs = await getAllBlockTxs(chronik, blockHeight);
-        } catch (err) {
-            console.log(`Error in getAllBlockTxs(${blockHeight})`, err);
-
-            // Default Telegram message if chronik API error
-            const errorTgMsg =
-                `New Block Found\n` +
-                `\n` +
-                `${blockHeight.toLocaleString('en-US')}\n` +
-                `\n` +
-                `${blockHash}\n` +
-                `\n` +
-                `<a href="${config.blockExplorer}/block/${blockHash}">explorer</a>`;
-
-            try {
-                return await telegramBot.sendMessage(
-                    channelId,
-                    errorTgMsg,
-                    config.tgMsgOptions,
-                );
-            } catch (err) {
-                console.log(
-                    `Error in telegramBot.sendMessage(channelId=${channelId}, msg=${errorTgMsg}, options=${config.tgMsgOptions}) called from handleBlockFinalized`,
-                    err,
-                );
-                return false;
-            }
-        }
-
-        const parsedBlock = parseBlockTxs(blockHash, blockHeight, blockTxs);
-
-        // Get token genesis info for token IDs in this block
-        const { tokenIds, outputScripts } = parsedBlock;
-
-        const tokenInfoMap = await getTokenInfoMap(chronik, tokenIds);
-
-        const outputScriptInfoMap = await getOutputscriptInfoMap(
-            chronik,
-            outputScripts,
-        );
-
-        // Get price info for tg msg, if available
-        const { coingeckoResponse, coingeckoPrices } = await getCoingeckoPrices(
-            config.priceApi,
-        );
-        const blockSummaryTgMsgs = getBlockTgMessage(
-            parsedBlock,
-            coingeckoPrices,
-            tokenInfoMap,
-            outputScriptInfoMap,
-        );
-
-        if (returnMocks) {
-            // returnMocks is used in the script function generateMocks
-            // Using it as a flag here ensures the script is always using the same function
-            // as the app
-            // Note you need coingeckoResponse so you can mock the axios response for coingecko
-            return {
-                blockTxs,
-                parsedBlock,
-                coingeckoResponse,
-                coingeckoPrices,
-                tokenInfoMap,
-                outputScriptInfoMap,
-                blockSummaryTgMsgs,
-                blockSummaryTgMsgsApiFailure: getBlockTgMessage(
-                    parsedBlock,
-                    false, // failed coingecko price lookup
-                    false, // failed chronik token ID lookup
-                ),
-            };
-        }
-
-        // Don't await, this can take some time to complete due to remote
-        // caching.
-        getNextStakingReward(blockHeight + 1, memoryCache);
-
-        // Broadcast block summary telegram message(s)
-        return await sendBlockSummary(
-            blockSummaryTgMsgs,
-            telegramBot,
-            channelId,
-            blockHeight,
-        );
-    },
-    /**
-     * Handle block invalidated event
-     * @param {ChronikClient} chronik
-     * @param {object} telegramBot
-     * @param {string} channelId
-     * @param {string} blockHash
-     * @param {number} blockHeight
-     * @param {number} blockTimestamp
-     * @param {object} coinbaseData
-     * @param {object} memoryCache
-     */
-    handleBlockInvalidated: async function (
-        chronik,
-        telegramBot,
-        channelId,
-        blockHash,
-        blockHeight,
-        blockTimestamp,
-        coinbaseData,
-        memoryCache,
-    ) {
-        const miner = getMinerFromCoinbaseTx(
-            coinbaseData.scriptsig,
-            coinbaseData.outputs,
-            miners,
-        );
-
-        const stakingRewardWinner = getStakerFromCoinbaseTx(
-            blockHeight,
-            coinbaseData.outputs,
-        );
-        let stakingRewardWinnerAddress = 'unknown';
-        if (stakingRewardWinner !== false) {
-            try {
-                stakingRewardWinnerAddress = cashaddr.encodeOutputScript(
-                    stakingRewardWinner.staker,
-                );
-            } catch (err) {
-                // Use the script
-                stakingRewardWinnerAddress = `script ${stakingRewardWinner.staker}`;
-            }
-        }
-
-        const reason = await guessRejectReason(
-            chronik,
-            blockHeight,
-            coinbaseData,
-            memoryCache,
-        );
-
-        const errorTgMsg =
-            `Block invalidated by avalanche\n` +
-            `\n` +
-            `Height: ${blockHeight.toLocaleString('en-US')}\n` +
-            `\n` +
-            `Hash: ${blockHash}` +
-            `\n` +
-            `Timestamp: ${blockTimestamp}\n` +
-            `Mined by ${miner}\n` +
-            `Staking reward winner: ${stakingRewardWinnerAddress}\n` +
-            `Guessed reject reason: ${reason}`;
-
-        try {
-            return await telegramBot.sendMessage(
-                channelId,
-                errorTgMsg,
-                config.tgMsgOptions,
-            );
-        } catch (err) {
-            console.log(
-                `Error in telegramBot.sendMessage(channelId=${channelId}, msg=${errorTgMsg}, options=${config.tgMsgOptions}) called from handleBlockInvalidated`,
-                err,
-            );
-        }
-    },
-    handleUtcMidnight: async function (chronik, telegramBot, channelId) {
-        // It is a new day
-        // Send the daily summary
-
-        // Get a datestring
-        // e.g. Wed Oct 23 2024
-        const dateString = new Date().toDateString();
-
-        // Get timestamp for UTC midnight
-        // Will always be divisible by 1000 as will always be a midnight UTC date
-        const MS_PER_S = 1000;
-        const newDayTimestamp = new Date(dateString).getTime() / MS_PER_S;
-
-        const SECONDS_PER_DAY = 86400;
-
-        const { startBlockheight, chaintip } =
-            await getBlocksAgoFromChaintipByTimestamp(
-                chronik,
-                newDayTimestamp,
-                SECONDS_PER_DAY,
-            );
-
-        const getAllBlockTxPromises = [];
-        for (let i = startBlockheight; i <= chaintip; i += 1) {
-            getAllBlockTxPromises.push(getAllBlockTxs(chronik, i));
-        }
-
-        const allBlockTxs = (await Promise.all(getAllBlockTxPromises)).flat();
-
-        // We only want txs in the specified window
-        // NB coinbase txs have timeFirstSeen of 0. We include all of them as the block
-        // timestamps are in the window
-        const timeFirstSeenTxs = allBlockTxs.filter(
-            tx =>
-                (tx.timeFirstSeen > newDayTimestamp - SECONDS_PER_DAY &&
-                    tx.timeFirstSeen <= newDayTimestamp) ||
-                tx.isCoinbase,
-        );
-
-        // Get tokenIds of all tokens seen in this batch of txs
-        const tokensToday = new Set();
-        for (const tx of timeFirstSeenTxs) {
-            const { tokenEntries } = tx;
-            for (const tokenEntry of tokenEntries) {
-                const { tokenId, groupTokenId } = tokenEntry;
-                tokensToday.add(tokenId);
-                if (typeof groupTokenId !== 'undefined') {
-                    // We want the groupTokenId info even if we only have child txs in this window
-                    tokensToday.add(groupTokenId);
-                }
-            }
-        }
-        // Get all the token info of tokens from today
-        const tokenInfoMap = await getTokenInfoMap(chronik, tokensToday);
-
-        // Get XEC price and market info
-        let priceInfo;
-        try {
-            priceInfo = (
-                await axios.get(
-                    `https://api.coingecko.com/api/v3/simple/price?ids=ecash&vs_currencies=usd&include_market_cap=true&include_24hr_vol=true&include_24hr_change=true`,
-                )
-            ).data.ecash;
-        } catch (err) {
-            console.error(`Error getting daily summary price info`, err);
-        }
-
-        const dailySummaryTgMsgs = summarizeTxHistory(
-            newDayTimestamp,
-            timeFirstSeenTxs,
-            tokenInfoMap,
-            priceInfo,
-        );
-
-        // Send msg with successful price API call
-        await sendBlockSummary(
-            dailySummaryTgMsgs,
-            telegramBot,
-            channelId,
-            'daily',
-        );
-    },
-};
diff --git a/apps/ecash-herald/src/events.ts b/apps/ecash-herald/src/events.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/src/events.ts
@@ -0,0 +1,342 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+'use strict';
+import config from '../config';
+import axios from 'axios';
+import cashaddr from 'ecashaddrjs';
+import {
+    parseBlockTxs,
+    getBlockTgMessage,
+    getMinerFromCoinbaseTx,
+    getStakerFromCoinbaseTx,
+    guessRejectReason,
+    summarizeTxHistory,
+    HeraldParsedBlock,
+} from './parse';
+import {
+    getCoingeckoPrices,
+    jsonReviver,
+    getNextStakingReward,
+    CoinGeckoPrice,
+} from './utils';
+import { sendBlockSummary } from './telegram';
+import {
+    getTokenInfoMap,
+    getOutputscriptInfoMap,
+    getAllBlockTxs,
+    getBlocksAgoFromChaintipByTimestamp,
+    OutputscriptInfo,
+} from './chronik';
+import knownMinersJson from '../constants/miners';
+import { ChronikClient, CoinbaseData, Tx, GenesisInfo } from 'chronik-client';
+import TelegramBot, { Message } from 'node-telegram-bot-api';
+import { MemoryCache } from 'cache-manager';
+import { MockTelegramBot } from '../test/mocks/telegramBotMock';
+
+const miners = JSON.parse(JSON.stringify(knownMinersJson), jsonReviver);
+
+// This is expected for TelegramBot.sendMessage but is not available in its types
+// Based on Telegram API docs
+export interface SendMessageResponse {
+    message_id: number;
+    from: {
+        id: number;
+        is_bot: boolean;
+        first_name: string;
+        username: string;
+    };
+    chat: {
+        id: number;
+        first_name: string;
+        username: string;
+        type: 'private';
+    };
+    date: number;
+    text: string;
+}
+
+export interface StoredMock {
+    blockTxs: Tx[];
+    parsedBlock: HeraldParsedBlock;
+    coingeckoResponse: any;
+    coingeckoPrices: CoinGeckoPrice[];
+    tokenInfoMap: Map<string, GenesisInfo>;
+    outputScriptInfoMap: Map<string, OutputscriptInfo>;
+    blockSummaryTgMsgs: string[];
+    blockSummaryTgMsgsApiFailure: string[];
+}
+
+/**
+ * Callback function for a new finalized block on the eCash blockchain
+ * Summarize on-chain activity in this block
+ * @param chronik
+ * @param telegramBot A connected telegramBot instance
+ * @param channelId The channel ID where the telegram msg(s) will be sent
+ * @param height blockheight
+ * @param returnMocks If true, return mocks for unit tests
+ * @param memoryCache
+ */
+export const handleBlockFinalized = async (
+    chronik: ChronikClient,
+    telegramBot: TelegramBot | MockTelegramBot,
+    channelId: string,
+    blockHash: string,
+    blockHeight: number,
+    memoryCache: MemoryCache,
+    returnMocks = false,
+): Promise<
+    | StoredMock
+    | Message
+    | SendMessageResponse
+    | boolean
+    | (Message | SendMessageResponse)[]
+> => {
+    // Get block txs
+    // TODO blockTxs are paginated, need a function to get them all
+    let blockTxs;
+    try {
+        blockTxs = await getAllBlockTxs(chronik, blockHeight);
+    } catch (err) {
+        console.log(`Error in getAllBlockTxs(${blockHeight})`, err);
+
+        // Default Telegram message if chronik API error
+        const errorTgMsg =
+            `New Block Found\n` +
+            `\n` +
+            `${blockHeight.toLocaleString('en-US')}\n` +
+            `\n` +
+            `${blockHash}\n` +
+            `\n` +
+            `<a href="${config.blockExplorer}/block/${blockHash}">explorer</a>`;
+
+        try {
+            return (await telegramBot.sendMessage(
+                channelId,
+                errorTgMsg,
+                config.tgMsgOptions,
+            )) as Message | SendMessageResponse;
+        } catch (err) {
+            console.log(
+                `Error in telegramBot.sendMessage(channelId=${channelId}, msg=${errorTgMsg}, options=${config.tgMsgOptions}) called from handleBlockFinalized`,
+                err,
+            );
+            return false;
+        }
+    }
+
+    const parsedBlock = parseBlockTxs(blockHash, blockHeight, blockTxs);
+
+    // Get token genesis info for token IDs in this block
+    const { tokenIds, outputScripts } = parsedBlock;
+
+    const tokenInfoMap = await getTokenInfoMap(chronik, tokenIds);
+
+    const outputScriptInfoMap = await getOutputscriptInfoMap(
+        chronik,
+        outputScripts,
+    );
+
+    // Get price info for tg msg, if available
+    const resp = await getCoingeckoPrices(config.priceApi);
+    const coingeckoPrices = resp !== false ? resp.coingeckoPrices : false;
+    const coingeckoResponse = resp !== false ? resp.coingeckoResponse : false;
+    const blockSummaryTgMsgs = getBlockTgMessage(
+        parsedBlock,
+        coingeckoPrices,
+        tokenInfoMap,
+        outputScriptInfoMap,
+    );
+
+    if (returnMocks) {
+        // returnMocks is used in the script function generateMocks
+        // Using it as a flag here ensures the script is always using the same function
+        // as the app
+        // Note you need coingeckoResponse so you can mock the axios response for coingecko
+        return {
+            blockTxs,
+            parsedBlock,
+            coingeckoResponse,
+            coingeckoPrices,
+            tokenInfoMap,
+            outputScriptInfoMap,
+            blockSummaryTgMsgs,
+            blockSummaryTgMsgsApiFailure: getBlockTgMessage(
+                parsedBlock,
+                false, // failed coingecko price lookup
+                false, // failed chronik token ID lookup
+                false, // failed balances lookup for output scripts
+            ),
+        } as StoredMock;
+    }
+
+    // Don't await, this can take some time to complete due to remote
+    // caching.
+    getNextStakingReward(blockHeight + 1, memoryCache);
+
+    // Broadcast block summary telegram message(s)
+    return await sendBlockSummary(
+        blockSummaryTgMsgs,
+        telegramBot,
+        channelId,
+        blockHeight,
+    );
+};
+/**
+ * Handle block invalidated event
+ * @param {ChronikClient} chronik
+ * @param {object} telegramBot
+ * @param {string} channelId
+ * @param {string} blockHash
+ * @param {number} blockHeight
+ * @param {number} blockTimestamp
+ * @param {object} coinbaseData
+ * @param {object} memoryCache
+ */
+export const handleBlockInvalidated = async (
+    chronik: ChronikClient,
+    telegramBot: TelegramBot | MockTelegramBot,
+    channelId: string,
+    blockHash: string,
+    blockHeight: number,
+    blockTimestamp: number,
+    coinbaseData: CoinbaseData,
+    memoryCache: MemoryCache,
+) => {
+    const miner = getMinerFromCoinbaseTx(
+        coinbaseData.scriptsig,
+        coinbaseData.outputs,
+        miners,
+    );
+
+    const stakingRewardWinner = getStakerFromCoinbaseTx(
+        blockHeight,
+        coinbaseData.outputs,
+    );
+    let stakingRewardWinnerAddress = 'unknown';
+    if (stakingRewardWinner !== false) {
+        try {
+            stakingRewardWinnerAddress = cashaddr.encodeOutputScript(
+                stakingRewardWinner.staker,
+            );
+        } catch (err) {
+            // Use the script
+            stakingRewardWinnerAddress = `script ${stakingRewardWinner.staker}`;
+        }
+    }
+
+    const reason = await guessRejectReason(
+        chronik,
+        blockHeight,
+        coinbaseData,
+        memoryCache,
+    );
+
+    const errorTgMsg =
+        `Block invalidated by avalanche\n` +
+        `\n` +
+        `Height: ${blockHeight.toLocaleString('en-US')}\n` +
+        `\n` +
+        `Hash: ${blockHash}` +
+        `\n` +
+        `Timestamp: ${blockTimestamp}\n` +
+        `Mined by ${miner}\n` +
+        `Staking reward winner: ${stakingRewardWinnerAddress}\n` +
+        `Guessed reject reason: ${reason}`;
+
+    try {
+        return await telegramBot.sendMessage(
+            channelId,
+            errorTgMsg,
+            config.tgMsgOptions,
+        );
+    } catch (err) {
+        console.log(
+            `Error in telegramBot.sendMessage(channelId=${channelId}, msg=${errorTgMsg}, options=${config.tgMsgOptions}) called from handleBlockInvalidated`,
+            err,
+        );
+    }
+};
+
+export const handleUtcMidnight = async (
+    chronik: ChronikClient,
+    telegramBot: TelegramBot,
+    channelId: string,
+) => {
+    // It is a new day
+    // Send the daily summary
+
+    // Get a datestring
+    // e.g. Wed Oct 23 2024
+    const dateString = new Date().toDateString();
+
+    // Get timestamp for UTC midnight
+    // Will always be divisible by 1000 as will always be a midnight UTC date
+    const MS_PER_S = 1000;
+    const newDayTimestamp = new Date(dateString).getTime() / MS_PER_S;
+
+    const SECONDS_PER_DAY = 86400;
+
+    const { startBlockheight, chaintip } =
+        await getBlocksAgoFromChaintipByTimestamp(
+            chronik,
+            newDayTimestamp,
+            SECONDS_PER_DAY,
+        );
+
+    const getAllBlockTxPromises = [];
+    for (let i = startBlockheight; i <= chaintip; i += 1) {
+        getAllBlockTxPromises.push(getAllBlockTxs(chronik, i));
+    }
+
+    const allBlockTxs = (await Promise.all(getAllBlockTxPromises)).flat();
+
+    // We only want txs in the specified window
+    // NB coinbase txs have timeFirstSeen of 0. We include all of them as the block
+    // timestamps are in the window
+    const timeFirstSeenTxs = allBlockTxs.filter(
+        (tx: Tx) =>
+            (tx.timeFirstSeen > newDayTimestamp - SECONDS_PER_DAY &&
+                tx.timeFirstSeen <= newDayTimestamp) ||
+            tx.isCoinbase,
+    );
+
+    // Get tokenIds of all tokens seen in this batch of txs
+    const tokensToday: Set<string> = new Set();
+    for (const tx of timeFirstSeenTxs) {
+        const { tokenEntries } = tx;
+        for (const tokenEntry of tokenEntries) {
+            const { tokenId, groupTokenId } = tokenEntry;
+            tokensToday.add(tokenId);
+            if (typeof groupTokenId !== 'undefined') {
+                // We want the groupTokenId info even if we only have child txs in this window
+                tokensToday.add(groupTokenId);
+            }
+        }
+    }
+    // Get all the token info of tokens from today
+    const tokenInfoMap = await getTokenInfoMap(chronik, tokensToday);
+
+    // Get XEC price and market info
+    let priceInfo;
+    try {
+        priceInfo = (
+            await axios.get(
+                `https://api.coingecko.com/api/v3/simple/price?ids=ecash&vs_currencies=usd&include_market_cap=true&include_24hr_vol=true&include_24hr_change=true`,
+            )
+        ).data.ecash;
+    } catch (err) {
+        console.error(`Error getting daily summary price info`, err);
+    }
+
+    const dailySummaryTgMsgs = summarizeTxHistory(
+        newDayTimestamp,
+        timeFirstSeenTxs,
+        tokenInfoMap,
+        priceInfo,
+    );
+
+    // Send msg with successful price API call
+    await sendBlockSummary(dailySummaryTgMsgs, telegramBot, channelId, 'daily');
+};
diff --git a/apps/ecash-herald/src/main.js b/apps/ecash-herald/src/main.js
deleted file mode 100644
--- a/apps/ecash-herald/src/main.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-const config = require('../config');
-const { caching } = require('cache-manager');
-const { initializeWebsocket } = require('./chronikWsHandler');
-
-module.exports = {
-    main: async function (chronik, telegramBot, telegramChannelId) {
-        // Initialize a cache
-        // Store data for config.cacheTtlMsecs
-        // We need to have staking reward data for the next block, which could be
-        // more than 10 minutes out pre-heartbeat
-        const CACHE_TTL = config.cacheTtlMsecs;
-        const memoryCache = await caching('memory', {
-            max: 100,
-            ttl: CACHE_TTL,
-        });
-        // Initialize websocket connection
-        try {
-            await initializeWebsocket(
-                chronik,
-                telegramBot,
-                telegramChannelId,
-                memoryCache,
-            );
-        } catch (err) {
-            console.log(
-                `Error initializing ecash-herald websocket connection`,
-                err,
-            );
-            console.log(`Failed to start ecash-herald.`);
-            return err;
-        }
-    },
-};
diff --git a/apps/ecash-herald/src/main.ts b/apps/ecash-herald/src/main.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/src/main.ts
@@ -0,0 +1,42 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import config from '../config';
+import { caching } from 'cache-manager';
+import { initializeWebsocket } from './chronikWsHandler';
+import { ChronikClient } from 'chronik-client';
+import TelegramBot from 'node-telegram-bot-api';
+import { MockTelegramBot } from '../test/mocks/telegramBotMock';
+
+export const main = async (
+    chronik: ChronikClient,
+    telegramBot: TelegramBot | MockTelegramBot,
+    telegramChannelId: string,
+) => {
+    // Initialize a cache
+    // Store data for config.cacheTtlMsecs
+    // We need to have staking reward data for the next block, which could be
+    // more than 10 minutes out pre-heartbeat
+    const CACHE_TTL = config.cacheTtlMsecs;
+    const memoryCache = await caching('memory', {
+        max: 100,
+        ttl: CACHE_TTL,
+    });
+    // Initialize websocket connection
+    try {
+        await initializeWebsocket(
+            chronik,
+            telegramBot,
+            telegramChannelId,
+            memoryCache,
+        );
+    } catch (err) {
+        console.log(
+            `Error initializing ecash-herald websocket connection`,
+            err,
+        );
+        console.log(`Failed to start ecash-herald.`);
+        return err;
+    }
+};
diff --git a/apps/ecash-herald/src/parse.js b/apps/ecash-herald/src/parse.js
deleted file mode 100644
--- a/apps/ecash-herald/src/parse.js
+++ /dev/null
@@ -1,3675 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-const config = require('../config');
-const opReturn = require('../constants/op_return');
-const { consume, consumeNextPush, swapEndianness } = require('ecash-script');
-const knownMinersJson = require('../constants/miners');
-const cachedTokenInfoMap = require('../constants/tokens');
-const { jsonReviver, bigNumberAmountToLocaleString } = require('../src/utils');
-const miners = JSON.parse(JSON.stringify(knownMinersJson), jsonReviver);
-const cashaddr = require('ecashaddrjs');
-const BigNumber = require('bignumber.js');
-const {
-    TOKEN_SERVER_OUTPUTSCRIPT,
-    BINANCE_OUTPUTSCRIPT,
-} = require('../constants/senders');
-const {
-    prepareStringForTelegramHTML,
-    splitOverflowTgMsg,
-} = require('./telegram');
-const {
-    formatPrice,
-    satsToFormattedValue,
-    returnAddressPreview,
-    containsOnlyPrintableAscii,
-} = require('./utils');
-const lokadMap = require('../constants/lokad');
-const { scriptOps } = require('ecash-agora');
-const { Script, fromHex, OP_0 } = require('ecash-lib');
-
-// Constants for SLP 1 token types as returned by chronik-client
-const SLP_1_PROTOCOL_NUMBER = 1;
-const SLP_1_NFT_COLLECTION_PROTOCOL_NUMBER = 129;
-const SLP_1_NFT_PROTOCOL_NUMBER = 65;
-
-// Miner fund output script
-const minerFundOutputScript = 'a914d37c4c809fe9840e7bfa77b86bd47163f6fb6c6087';
-
-module.exports = {
-    /**
-     * Parse a finalized block for newsworthy information
-     * @param {string} blockHash
-     * @param {number} blockHeight
-     * @param {Tx_InNode[]} txs
-     */
-    parseBlockTxs: function (blockHash, blockHeight, txs) {
-        // Parse coinbase string
-        const coinbaseTx = txs[0];
-        const miner = module.exports.getMinerFromCoinbaseTx(
-            coinbaseTx.inputs[0].inputScript,
-            coinbaseTx.outputs,
-            miners,
-        );
-        let staker = module.exports.getStakerFromCoinbaseTx(
-            blockHeight,
-            coinbaseTx.outputs,
-        );
-        try {
-            staker.staker = cashaddr.encodeOutputScript(staker.staker);
-        } catch (err) {
-            staker.staker = 'script(' + staker.staker + ')';
-        }
-
-        // Start with i=1 to skip Coinbase tx
-        let parsedTxs = [];
-        for (let i = 1; i < txs.length; i += 1) {
-            parsedTxs.push(module.exports.parseTx(txs[i]));
-        }
-
-        // Sort parsedTxs by totalSatsSent, highest to lowest
-        parsedTxs = parsedTxs.sort((a, b) => {
-            return b.totalSatsSent - a.totalSatsSent;
-        });
-
-        // Collect token info needed to parse token send txs
-        const tokenIds = new Set(); // we only need each tokenId once
-        // Collect outputScripts seen in this block to parse for balance
-        let outputScripts = new Set();
-        for (let i = 0; i < parsedTxs.length; i += 1) {
-            const thisParsedTx = parsedTxs[i];
-            if (thisParsedTx.tokenSendInfo) {
-                tokenIds.add(thisParsedTx.tokenSendInfo.tokenId);
-            }
-            if (thisParsedTx.genesisInfo) {
-                tokenIds.add(thisParsedTx.genesisInfo.tokenId);
-            }
-            if (thisParsedTx.tokenBurnInfo) {
-                tokenIds.add(thisParsedTx.tokenBurnInfo.tokenId);
-            }
-            // Some OP_RETURN txs also have token IDs we need to parse
-            // SWaP txs, (TODO: airdrop txs)
-            if (
-                thisParsedTx.opReturnInfo &&
-                thisParsedTx.opReturnInfo.tokenId
-            ) {
-                tokenIds.add(thisParsedTx.opReturnInfo.tokenId);
-            }
-            const { xecSendingOutputScripts, xecReceivingOutputs } =
-                thisParsedTx;
-
-            // Only add the first sending and receiving output script,
-            // As you will only render balance emojis for these
-            outputScripts.add(xecSendingOutputScripts.values().next().value);
-
-            // For receiving outputScripts, add the first that is not OP_RETURN
-            // So, get an array of the outputScripts first
-            const xecReceivingOutputScriptsArray = Array.from(
-                xecReceivingOutputs.keys(),
-            );
-            for (let j = 0; j < xecReceivingOutputScriptsArray.length; j += 1) {
-                if (
-                    !xecReceivingOutputScriptsArray[j].startsWith(
-                        opReturn.opReturnPrefix,
-                    )
-                ) {
-                    outputScripts.add(xecReceivingOutputScriptsArray[j]);
-                    // Exit loop after you've added the first non-OP_RETURN outputScript
-                    break;
-                }
-            }
-        }
-        return {
-            hash: blockHash,
-            height: blockHeight,
-            miner,
-            staker,
-            numTxs: txs.length,
-            parsedTxs,
-            tokenIds,
-            outputScripts,
-        };
-    },
-    getStakerFromCoinbaseTx: function (blockHeight, coinbaseOutputs) {
-        const STAKING_ACTIVATION_HEIGHT = 818670;
-        if (blockHeight < STAKING_ACTIVATION_HEIGHT) {
-            // Do not parse for staking rwds if they are not expected to exist
-            return false;
-        }
-        const STAKING_REWARDS_PERCENT = 10;
-        const totalCoinbaseSats = coinbaseOutputs
-            .map(output => parseInt(output.value))
-            .reduce((prev, curr) => prev + curr, 0);
-        for (let output of coinbaseOutputs) {
-            const thisValue = parseInt(output.value);
-            const minStakerValue = Math.floor(
-                totalCoinbaseSats * STAKING_REWARDS_PERCENT * 0.01,
-            );
-            // In practice, the staking reward will almost always be the one that is exactly 10% of totalCoinbaseSats
-            // Use a STAKER_PERCENT_PADDING range to exclude miner and ifp outputs
-            const STAKER_PERCENT_PADDING = 1;
-            const assumedMaxStakerValue = Math.floor(
-                totalCoinbaseSats *
-                    (STAKING_REWARDS_PERCENT + STAKER_PERCENT_PADDING) *
-                    0.01,
-            );
-            if (
-                thisValue >= minStakerValue &&
-                thisValue <= assumedMaxStakerValue
-            ) {
-                return {
-                    // Return the script, there is no guarantee that we can use
-                    // an address to display this.
-                    staker: output.outputScript,
-                    reward: thisValue,
-                };
-            }
-        }
-        // If you don't find a staker, don't add it in msg. Can troubleshoot if see this in the app.
-        // This can happen if a miner overpays rwds, underpays miner rwds
-        return false;
-    },
-    getMinerFromCoinbaseTx: function (
-        coinbaseScriptsig,
-        coinbaseOutputs,
-        knownMiners,
-    ) {
-        // When you find the miner, minerInfo will come from knownMiners
-        let minerInfo = false;
-
-        // First, check outputScripts for a known miner
-        for (let i = 0; i < coinbaseOutputs.length; i += 1) {
-            const thisOutputScript = coinbaseOutputs[i].outputScript;
-            if (knownMiners.has(thisOutputScript)) {
-                minerInfo = knownMiners.get(thisOutputScript);
-                break;
-            }
-        }
-
-        if (!minerInfo) {
-            // If you still haven't found minerInfo, test by known pattern of coinbase script
-            // Possibly a known miner is using a new address
-            knownMiners.forEach(knownMinerInfo => {
-                const { coinbaseHexFragment } = knownMinerInfo;
-                if (coinbaseScriptsig.includes(coinbaseHexFragment)) {
-                    minerInfo = knownMinerInfo;
-                }
-            });
-        }
-
-        if (!minerInfo) {
-            // We're still unable to identify the miner, so resort to
-            // indentifying by the last chars of the payout address. For now
-            // we assume the ordering of outputs such as the miner reward is at
-            // the first position.
-            const minerPayoutSript = coinbaseOutputs[0].outputScript;
-            try {
-                const minerAddress =
-                    cashaddr.encodeOutputScript(minerPayoutSript);
-                return `unknown, ...${minerAddress.slice(-4)}`;
-            } catch (err) {
-                console.log(
-                    `Error converting miner payout script (${minerPayoutSript}) to eCash address`,
-                    err,
-                );
-                // Give up
-                return 'unknown';
-            }
-        }
-
-        // If you have found the miner, parse coinbase hex for additional info
-        switch (minerInfo.miner) {
-            // This is available for ViaBTC and CK Pool
-            // Use a switch statement to easily support adding future miners
-            case 'ViaBTC':
-            // Intentional fall-through so ViaBTC and CKPool have same parsing
-            // es-lint ignore no-fallthrough
-            case 'CK Pool': {
-                /* For ViaBTC, the interesting info is between '/' characters
-                 * i.e. /Mined by 260786/
-                 * In ascii, these are encoded with '2f'
-                 */
-                const infoHexParts = coinbaseScriptsig.split('2f');
-
-                // Because the characters before and after the info we are looking for could also
-                // contain '2f', we need to find the right part
-
-                // The right part is the one that comes immediately after coinbaseHexFragment
-                let infoAscii = '';
-                for (let i = 0; i < infoHexParts.length; i += 1) {
-                    if (
-                        infoHexParts[i].includes(minerInfo.coinbaseHexFragment)
-                    ) {
-                        // We want the next one, if it exists
-                        if (i + 1 < infoHexParts.length) {
-                            infoAscii = Buffer.from(
-                                infoHexParts[i + 1],
-                                'hex',
-                            ).toString('ascii');
-                        }
-                        break;
-                    }
-                }
-
-                if (infoAscii === 'mined by IceBerg') {
-                    // CK Pool, mined by IceBerg
-                    // If this is IceBerg, identify uniquely
-                    // Iceberg is probably a solo miner using CK Pool software
-                    return `IceBerg`;
-                }
-
-                if (infoAscii === 'mined by iceberg') {
-                    // If the miner self identifies as iceberg, go with it
-                    return `iceberg`;
-                }
-
-                // Return your improved 'miner' info
-                // ViaBTC, Mined by 260786
-                if (infoAscii.length === 0) {
-                    // If you did not find anything interesting, just return the miner
-                    return minerInfo.miner;
-                }
-                return `${minerInfo.miner}, ${infoAscii}`;
-            }
-            default: {
-                // Unless the miner has specific parsing rules defined above, no additional info is available
-                return minerInfo.miner;
-            }
-        }
-    },
-    parseTx: function (tx) {
-        /* Parse an eCash tx as returned by chronik for newsworthy information
-         * returns
-         * { txid, genesisInfo, opReturnInfo }
-         */
-
-        const { txid, inputs, outputs } = tx;
-
-        let isTokenTx = false;
-        let genesisInfo = false;
-        let opReturnInfo = false;
-
-        /* Token send parsing info
-         *
-         * Note that token send amounts received from chronik do not account for
-         * token decimals. Decimalized amounts require token genesisInfo
-         * decimals param to calculate
-         */
-
-        /* tokenSendInfo
-         * `false` for txs that are not etoken send txs
-         * an object containing info about the token send for token send txs
-         */
-        let tokenSendInfo = false;
-        let tokenSendingOutputScripts = new Set();
-        let tokenReceivingOutputs = new Map();
-        let tokenChangeOutputs = new Map();
-        let undecimalizedTokenInputAmount = new BigNumber(0);
-
-        // tokenBurn parsing variables
-        let tokenBurnInfo = false;
-
-        /* Collect xecSendInfo for all txs, since all txs are XEC sends
-         * You may later want to render xecSendInfo for tokenSends, appTxs, etc,
-         * maybe on special conditions, e.g.a token send tx that also sends a bunch of xec
-         */
-
-        // xecSend parsing variables
-        let xecSendingOutputScripts = new Set();
-        let xecReceivingOutputs = new Map();
-        let xecInputAmountSats = 0;
-        let xecOutputAmountSats = 0;
-        let totalSatsSent = 0;
-        let changeAmountSats = 0;
-
-        if (
-            tx.tokenStatus !== 'TOKEN_STATUS_NON_TOKEN' &&
-            tx.tokenEntries.length > 0
-        ) {
-            isTokenTx = true;
-
-            // We may have more than one token action in a given tx
-            // chronik will reflect this by having multiple entries in the tokenEntries array
-
-            // For now, just parse the first action
-            // TODO handle txs with multiple tokenEntries
-            const parsedTokenAction = tx.tokenEntries[0];
-
-            const {
-                tokenId,
-                tokenType,
-                txType,
-                burnSummary,
-                actualBurnAmount,
-            } = parsedTokenAction;
-            const { protocol, number } = tokenType;
-            const isUnintentionalBurn =
-                burnSummary !== '' && actualBurnAmount !== '0';
-
-            // Get token type
-            // TODO present the token type in msgs
-            let parsedTokenType = '';
-            switch (protocol) {
-                case 'ALP': {
-                    parsedTokenType = 'ALP';
-                    break;
-                }
-                case 'SLP': {
-                    if (number === SLP_1_PROTOCOL_NUMBER) {
-                        parsedTokenType = 'SLP';
-                    } else if (
-                        number === SLP_1_NFT_COLLECTION_PROTOCOL_NUMBER
-                    ) {
-                        parsedTokenType = 'NFT Collection';
-                    } else if (number === SLP_1_NFT_PROTOCOL_NUMBER) {
-                        parsedTokenType = 'NFT';
-                    }
-                    break;
-                }
-                default: {
-                    parsedTokenType = `${protocol} ${number}`;
-                    break;
-                }
-            }
-
-            switch (txType) {
-                case 'GENESIS': {
-                    // Note that NNG chronik provided genesisInfo in this tx
-                    // Now we get it from chronik.token
-                    // Initialize genesisInfo object with tokenId so it can be rendered into a msg later
-                    genesisInfo = { tokenId };
-                    break;
-                }
-                case 'SEND': {
-                    if (isUnintentionalBurn) {
-                        tokenBurnInfo = {
-                            tokenId,
-                            undecimalizedTokenBurnAmount: actualBurnAmount,
-                        };
-                    } else {
-                        tokenSendInfo = {
-                            tokenId,
-                            parsedTokenType,
-                            txType,
-                        };
-                    }
-                    break;
-                }
-                // TODO handle MINT
-                default: {
-                    // For now, if we can't parse as above, this will be parsed as an eCash tx (or EMPP)
-                    break;
-                }
-            }
-        }
-        for (const input of inputs) {
-            xecSendingOutputScripts.add(input.outputScript);
-            xecInputAmountSats += input.value;
-            // The input that sent the token utxos will have key 'slpToken'
-            if (typeof input.token !== 'undefined') {
-                // Add amount to undecimalizedTokenInputAmount
-                // TODO make sure this is for the correct tokenID
-                // Could have mistakes in parsing ALP txs otherwise
-                // For now, this is outside the scope of migration
-                undecimalizedTokenInputAmount =
-                    undecimalizedTokenInputAmount.plus(input.token.amount);
-                // Collect the input outputScripts to identify change output
-                tokenSendingOutputScripts.add(input.outputScript);
-            }
-        }
-
-        // Iterate over outputs to check for OP_RETURN msgs
-        for (const output of outputs) {
-            const { value, outputScript } = output;
-            xecOutputAmountSats += value;
-            // If this output script is the same as one of the sendingOutputScripts
-            if (xecSendingOutputScripts.has(outputScript)) {
-                // Then this XEC amount is change
-                changeAmountSats += value;
-            } else {
-                // Add an xecReceivingOutput
-
-                // Add outputScript and value to map
-                // If this outputScript is already in xecReceivingOutputs, increment its value
-                xecReceivingOutputs.set(
-                    outputScript,
-                    (xecReceivingOutputs.get(outputScript) ?? 0) + value,
-                );
-
-                // Increment totalSatsSent
-                totalSatsSent += value;
-            }
-            // Don't parse OP_RETURN values of etoken txs, this info is available from chronik
-            if (
-                outputScript.startsWith(opReturn.opReturnPrefix) &&
-                !isTokenTx
-            ) {
-                opReturnInfo = module.exports.parseOpReturn(
-                    outputScript.slice(2),
-                );
-            }
-            // For etoken send txs, parse outputs for tokenSendInfo object
-            if (typeof output.token !== 'undefined') {
-                // TODO handle EMPP and potential token txs with multiple tokens involved
-                // Check output script to confirm does not match tokenSendingOutputScript
-                if (tokenSendingOutputScripts.has(outputScript)) {
-                    // change
-                    tokenChangeOutputs.set(
-                        outputScript,
-                        (
-                            tokenChangeOutputs.get(outputScript) ??
-                            new BigNumber(0)
-                        ).plus(output.token.amount),
-                    );
-                } else {
-                    /* This is the sent token qty
-                     *
-                     * Add outputScript and undecimalizedTokenReceivedAmount to map
-                     * If this outputScript is already in tokenReceivingOutputs, increment undecimalizedTokenReceivedAmount
-                     * note that thisOutput.slpToken.amount is a string so you do not want to add it
-                     * BigNumber library is required for token calculations
-                     */
-                    tokenReceivingOutputs.set(
-                        outputScript,
-                        (
-                            tokenReceivingOutputs.get(outputScript) ??
-                            new BigNumber(0)
-                        ).plus(output.token.amount),
-                    );
-                }
-            }
-        }
-
-        // Determine tx fee
-        const txFee = xecInputAmountSats - xecOutputAmountSats;
-
-        // If this is a token send tx, return token send parsing info and not 'false' for tokenSendInfo
-        if (tokenSendInfo) {
-            tokenSendInfo.tokenChangeOutputs = tokenChangeOutputs;
-            tokenSendInfo.tokenReceivingOutputs = tokenReceivingOutputs;
-            tokenSendInfo.tokenSendingOutputScripts = tokenSendingOutputScripts;
-        }
-
-        // If this tx sent XEC to itself, reassign changeAmountSats to totalSatsSent
-        // Need to do this to prevent self-send txs being sorted at the bottom of msgs
-        if (xecReceivingOutputs.size === 0) {
-            totalSatsSent = changeAmountSats;
-        }
-
-        return {
-            txid,
-            genesisInfo,
-            opReturnInfo,
-            txFee,
-            xecSendingOutputScripts,
-            xecReceivingOutputs,
-            totalSatsSent,
-            tokenSendInfo,
-            tokenBurnInfo,
-        };
-    },
-    /**
-     *
-     * @param {string} opReturnHex an OP_RETURN outputScript with '6a' removed
-     * @returns {object} {app, msg} an object with app and msg params used to generate msg
-     */
-    parseOpReturn: function (opReturnHex) {
-        // Initialize required vars
-        let app;
-        let msg;
-        let tokenId = false;
-
-        // Get array of pushes
-        let stack = { remainingHex: opReturnHex };
-        let stackArray = [];
-        while (stack.remainingHex.length > 0) {
-            const { data } = consumeNextPush(stack);
-            if (data !== '') {
-                // You may have an empty push in the middle of a complicated tx for some reason
-                // Mb some libraries erroneously create these
-                // e.g. https://explorer.e.cash/tx/70c2842e1b2c7eb49ee69cdecf2d6f3cd783c307c4cbeef80f176159c5891484
-                // has 4c000100 for last characters. 4c00 is just nothing.
-                // But you want to know 00 and have the correct array index
-                stackArray.push(data);
-            }
-        }
-
-        // Get the protocolIdentifier, the first push
-        const protocolIdentifier = stackArray[0];
-
-        // Test for memo
-        // Memo prefixes are special in that they are two bytes instead of the usual four
-        // Also, memo has many prefixes, in that the action is also encoded in these two bytes
-        if (
-            protocolIdentifier.startsWith(opReturn.memo.prefix) &&
-            protocolIdentifier.length === 4
-        ) {
-            // If the protocol identifier is two bytes long (4 characters), parse for memo tx
-            // For now, send the same info to this function that it currently parses
-            // TODO parseMemoOutputScript needs to be refactored to use ecash-script
-            return module.exports.parseMemoOutputScript(stackArray);
-        }
-
-        // Test for other known apps with known msg processing methods
-        switch (protocolIdentifier) {
-            case opReturn.opReserved: {
-                // Parse for empp OP_RETURN
-                // Spec https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/chronik/bitcoinsuite-slp/src/empp/mod.rs
-                return module.exports.parseMultipushStack(stackArray);
-            }
-            case opReturn.knownApps.alias.prefix: {
-                app = opReturn.knownApps.alias.app;
-                /*
-                For now, parse and render alias txs by going through OP_RETURN
-                When aliases are live, refactor to use alias-server for validation
-                <protocolIdentifier> <version> <alias> <address type + hash>
-
-                Only parse the msg if the tx is constructed correctly
-                */
-                msg =
-                    stackArray.length === 4 && stackArray[1] === '00'
-                        ? prepareStringForTelegramHTML(
-                              Buffer.from(stackArray[2], 'hex').toString(
-                                  'utf8',
-                              ),
-                          )
-                        : 'Invalid alias registration';
-
-                break;
-            }
-            case opReturn.knownApps.airdrop.prefix: {
-                app = opReturn.knownApps.airdrop.app;
-
-                // Initialize msg as empty string. Need tokenId info to complete.
-                msg = '';
-
-                // Airdrop tx has structure
-                // <prefix> <tokenId>
-
-                // Cashtab allows sending a cashtab msg with an airdrop
-                // These look like
-                // <prefix> <tokenId> <cashtabMsgPrefix> <msg>
-                if (stackArray.length >= 2 && stackArray[1].length === 64) {
-                    tokenId = stackArray[1];
-                }
-                break;
-            }
-            case opReturn.knownApps.cashtabMsg.prefix: {
-                app = opReturn.knownApps.cashtabMsg.app;
-                // For a Cashtab msg, the next push on the stack is the Cashtab msg
-                // Cashtab msgs use utf8 encoding
-
-                // Valid Cashtab Msg
-                // <protocol identifier> <msg in utf8>
-                msg =
-                    stackArray.length >= 2
-                        ? prepareStringForTelegramHTML(
-                              Buffer.from(stackArray[1], 'hex').toString(
-                                  'utf8',
-                              ),
-                          )
-                        : `Invalid ${app}`;
-                break;
-            }
-            case opReturn.knownApps.cashtabMsgEncrypted.prefix: {
-                app = opReturn.knownApps.cashtabMsgEncrypted.app;
-                // For an encrypted cashtab msg, you can't parse and display the msg
-                msg = '';
-                // You will add info about the tx when you build the msg
-                break;
-            }
-            case opReturn.knownApps.fusionLegacy.prefix:
-            case opReturn.knownApps.fusion.prefix: {
-                /**
-                 * Cash Fusion tx
-                 * <protocolIdentifier> <sessionHash>
-                 * https://github.com/cashshuffle/spec/blob/master/CASHFUSION.md
-                 */
-                app = opReturn.knownApps.fusion.app;
-                // The session hash is not particularly interesting to users
-                // Provide tx info in telegram prep function
-                msg = '';
-                break;
-            }
-            case opReturn.knownApps.swap.prefix: {
-                // Swap txs require special parsing that should be done in getSwapTgMsg
-                // We may need to get info about a token ID before we can
-                // create a good msg
-                app = opReturn.knownApps.swap.app;
-                msg = '';
-
-                if (
-                    stackArray.length >= 3 &&
-                    stackArray[1] === '01' &&
-                    stackArray[2] === '01' &&
-                    stackArray[3].length === 64
-                ) {
-                    // If this is a signal for buy or sell of a token, save the token id
-                    // Ref https://github.com/vinarmani/swap-protocol/blob/master/swap-protocol-spec.md
-                    // A buy or sell signal tx will have '01' at stackArray[1] and stackArray[2] and
-                    // token id at stackArray[3]
-                    tokenId = stackArray[3];
-                }
-                break;
-            }
-            case opReturn.knownApps.payButton.prefix: {
-                app = opReturn.knownApps.payButton.app;
-                // PayButton v0
-                // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/doc/standards/paybutton.md
-                // <lokad> <OP_0> <data> <nonce>
-                // The data could be interesting, ignore the rest
-                if (stackArray.length >= 3) {
-                    // Version byte is at index 1
-                    const payButtonTxVersion = stackArray[1];
-                    if (payButtonTxVersion !== '00') {
-                        msg = `Unsupported version: 0x${payButtonTxVersion}`;
-                    } else {
-                        const dataPush = stackArray[2];
-                        if (dataPush === '00') {
-                            // Per spec, PayButton txs with no data push OP_0 in this position
-                            msg = 'no data';
-                        } else {
-                            // Data is utf8 encoded
-                            msg = prepareStringForTelegramHTML(
-                                Buffer.from(stackArray[2], 'hex').toString(
-                                    'utf8',
-                                ),
-                            );
-                        }
-                    }
-                } else {
-                    msg = '[off spec]';
-                }
-                break;
-            }
-            case opReturn.knownApps.paywall.prefix: {
-                app = opReturn.knownApps.paywall.app;
-                // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/doc/standards/op_return-prefix-guideline.md
-                // <lokad> <txid of the article this paywall is paying for>
-                if (stackArray.length === 2) {
-                    const articleTxid = stackArray[1];
-                    if (
-                        typeof articleTxid === 'undefined' ||
-                        articleTxid.length !== 64
-                    ) {
-                        msg = `Invalid paywall article txid`;
-                    } else {
-                        msg = `<a href="${config.blockExplorer}/tx/${articleTxid}">Article paywall payment</a>`;
-                    }
-                } else {
-                    msg = '[off spec paywall payment]';
-                }
-                break;
-            }
-            case opReturn.knownApps.authentication.prefix: {
-                app = opReturn.knownApps.authentication.app;
-                // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/doc/standards/op_return-prefix-guideline.md
-                // <lokad> <authentication identifier>
-                if (stackArray.length === 2) {
-                    const authenticationHex = stackArray[1];
-                    if (authenticationHex === '00') {
-                        msg = `Invalid eCashChat authentication identifier`;
-                    } else {
-                        msg = 'eCashChat authentication via dust tx';
-                    }
-                } else {
-                    msg = '[off spec eCashChat authentication]';
-                }
-                break;
-            }
-            default: {
-                // If you do not recognize the protocol identifier, just print the pushes in hex
-                // If it is an app or follows a pattern, can be added later
-                app = 'unknown';
-
-                if (containsOnlyPrintableAscii(stackArray.join(''))) {
-                    msg = prepareStringForTelegramHTML(
-                        Buffer.from(stackArray.join(''), 'hex').toString(
-                            'ascii',
-                        ),
-                    );
-                } else {
-                    // If you have non-ascii characters, print each push as a hex number
-                    msg = '';
-                    for (let i = 0; i < stackArray.length; i += 1) {
-                        msg += `0x${stackArray[i]} `;
-                    }
-                    // Remove the last space
-                    msg = msg.slice(0, -1);
-
-                    // Trim the msg for Telegram to avoid 200+ char msgs
-                    const unknownMaxChars = 20;
-                    if (msg.length > unknownMaxChars) {
-                        msg = msg.slice(0, unknownMaxChars) + '...';
-                    }
-                }
-
-                break;
-            }
-        }
-
-        return { app, msg, stackArray, tokenId };
-    },
-    /**
-     * Parse an empp stack for a simplified slp v2 description
-     * TODO expand for parsing other types of empp txs as specs or examples are known
-     * @param {array} emppStackArray an array containing a hex string for every push of this memo OP_RETURN outputScript
-     * @returns {object} {app, msg} used to compose a useful telegram msg describing the transaction
-     */
-    parseMultipushStack: function (emppStackArray) {
-        // Note that an empp push may not necessarily include traditionally parsed pushes
-        // i.e. consumeNextPush({remainingHex:<emppPush>}) may throw an error
-        // For example, SLPv2 txs do not include a push for their prefix
-
-        // So, parsing empp txs will require specific rules depending on the type of tx
-        let msgs = [];
-
-        // Start at i=1 because emppStackArray[0] is OP_RESERVED
-        for (let i = 1; i < emppStackArray.length; i += 1) {
-            if (
-                emppStackArray[i].slice(0, 8) === opReturn.knownApps.slp2.prefix
-            ) {
-                // Parse string for slp v2
-                const thisMsg = module.exports.parseSlpTwo(
-                    emppStackArray[i].slice(8),
-                );
-                msgs.push(`${opReturn.knownApps.slp2.app}:${thisMsg}`);
-            } else {
-                // Since we don't know any spec or parsing rules for other types of EMPP pushes,
-                // Just add an ASCII decode of the whole thing if you see one
-                msgs.push(
-                    `${'Unknown App:'}${Buffer.from(
-                        emppStackArray[i],
-                        'hex',
-                    ).toString('ascii')}`,
-                );
-            }
-            // Do not parse any other empp (haven't seen any in the wild, no existing specs to follow)
-        }
-        if (msgs.length > 0) {
-            return { app: 'EMPP', msg: msgs.join('|') };
-        }
-    },
-    /**
-     * Stub method to parse slp two empps
-     * @param {string} slpTwoPush a string of hex characters in an empp tx representing an slp2 push
-     * @returns {string} For now, just the section type, if token type is correct
-     */
-    parseSlpTwo: function (slpTwoPush) {
-        // Parse an empp push hex string with the SLP protocol identifier removed per SLP v2 spec
-        // https://ecashbuilders.notion.site/SLPv2-a862a4130877448387373b9e6a93dd97
-
-        let msg = '';
-
-        // Create a stack to use ecash-script consume function
-        // Note: slp2 parsing is not standard op_return parsing, varchar bytes just use a one-byte push
-        // So, you can use the 'consume' function of ecash-script, but not consumeNextPush
-        let stack = { remainingHex: slpTwoPush };
-
-        // 1.3: Read token type
-        // For now, this can only be 00. If not 00, unknown
-        const tokenType = consume(stack, 1);
-
-        if (tokenType !== '00') {
-            msg += 'Unknown token type|';
-        }
-
-        // 1.4: Read section type
-        // These are custom varchar per slp2 spec
-        // <varchar byte hex> <section type>
-        const sectionBytes = parseInt(consume(stack, 1), 16);
-        // Note: these are encoded with push data, so you can use ecash-script
-
-        const sectionType = Buffer.from(
-            consume(stack, sectionBytes),
-            'hex',
-        ).toString('utf8');
-        msg += sectionType;
-
-        // Parsing differs depending on section type
-        // Note that SEND and MINT have same parsing
-
-        const TOKEN_ID_BYTES = 32;
-        switch (sectionType) {
-            case 'SEND':
-            case 'MINT': {
-                // Next up is tokenId
-                const tokenId = swapEndianness(consume(stack, TOKEN_ID_BYTES));
-
-                const cachedTokenInfo = cachedTokenInfoMap.get(tokenId);
-
-                msg += `|<a href="${config.blockExplorer}/tx/${tokenId}">${
-                    typeof cachedTokenInfo === 'undefined'
-                        ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
-                        : prepareStringForTelegramHTML(
-                              cachedTokenInfo.tokenTicker,
-                          )
-                }</a>`;
-
-                const numOutputs = consume(stack, 1);
-                // Iterate over number of outputs to get total amount sent
-                // Note: this should be handled with an indexer, as we are not parsing for validity here
-                // However, it's still useful information for the herald
-                let totalAmountSent = 0;
-                for (let i = 0; i < numOutputs; i += 1) {
-                    totalAmountSent += parseInt(
-                        swapEndianness(consume(stack, 6)),
-                    );
-                }
-                msg +=
-                    typeof cachedTokenInfo === 'undefined'
-                        ? ''
-                        : `|${bigNumberAmountToLocaleString(
-                              totalAmountSent.toString(),
-                              cachedTokenInfo.decimals,
-                          )}`;
-                break;
-            }
-
-            case 'GENESIS': {
-                // TODO
-                // Have not seen one of these in the wild yet
-                break;
-            }
-
-            case 'BURN': {
-                // TODO
-                // Have seen some in the wild but not in spec
-                break;
-            }
-        }
-        // The rest of the parsing rules get quite complicated and should be handled in a dedicated library
-        // or indexer
-        return msg;
-    },
-    /**
-     * Parse a stackArray according to OP_RETURN rules to convert to a useful tg msg
-     * @param {Array} stackArray an array containing a hex string for every push of this memo OP_RETURN outputScript
-     * @returns {string} A useful string to describe this tx in a telegram msg
-     */
-    parseMemoOutputScript: function (stackArray) {
-        let app = opReturn.memo.app;
-        let msg = '';
-
-        // Get the action code from stackArray[0]
-        // For memo txs, this will be the last 2 characters of this initial push
-        const actionCode = stackArray[0].slice(-2);
-
-        if (Object.keys(opReturn.memo).includes(actionCode)) {
-            // If you parse for this action code, include its description in the tg msg
-            msg += opReturn.memo[actionCode];
-            // Include a formatting spacer in between action code and newsworthy info
-            msg += '|';
-        }
-
-        switch (actionCode) {
-            case '01': // Set name <name> (1-217 bytes)
-            case '02': // Post memo <message> (1-217 bytes)
-            case '05': // Set profile text <text> (1-217 bytes)
-            case '0d': // Topic Follow <topic_name> (1-214 bytes)
-            case '0e': // Topic Unfollow <topic_name> (1-214 bytes)
-                // Action codes with only 1 push after the protocol identifier
-                // that is utf8 encoded
-
-                // Include decoded utf8 msg
-                // Make sure the OP_RETURN msg does not contain telegram html escape characters
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[1], 'hex').toString('utf8'),
-                );
-                break;
-            case '03':
-                /**
-                 * 03 - Reply to memo
-                 * <tx_hash> (32 bytes)
-                 * <message> (1-184 bytes)
-                 */
-
-                // The tx hash is in hex, not utf8 encoded
-                // For now, we don't have much to do with this txid in a telegram bot
-
-                // Link to the liked or reposted memo
-                // Do not remove tg escape characters as you want this to parse
-                msg += `<a href="${config.blockExplorer}/tx/${stackArray[1]}">memo</a>`;
-
-                // Include a formatting spacer
-                msg += '|';
-
-                // Add the reply
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[2], 'hex').toString('utf8'),
-                );
-                break;
-            case '04':
-                /**
-                 * 04 - Like / tip memo <tx_hash> (32 bytes)
-                 */
-
-                // Link to the liked or reposted memo
-                msg += `<a href="${config.blockExplorer}/tx/${stackArray[1]}">memo</a>`;
-                break;
-            case '0b': {
-                // 0b - Repost memo <tx_hash> (32 bytes) <message> (0-184 bytes)
-
-                // Link to the liked or reposted memo
-                msg += `<a href="${config.blockExplorer}/tx/${stackArray[1]}">memo</a>`;
-
-                // Include a formatting spacer
-                msg += '|';
-
-                // Add the msg
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[2], 'hex').toString('utf8'),
-                );
-
-                break;
-            }
-            case '06':
-            case '07':
-            case '16':
-            case '17': {
-                /**
-                 * Follow user - 06 <address> (20 bytes)
-                 * Unfollow user - 07 <address> (20 bytes)
-                 * Mute user - 16 <address> (20 bytes)
-                 * Unmute user - 17 <address> (20 bytes)
-                 */
-
-                // The address is a hex-encoded hash160
-                // all memo addresses are p2pkh
-                const address = cashaddr.encode(
-                    'ecash',
-                    'P2PKH',
-                    stackArray[1],
-                );
-
-                // Link to the address in the msg
-                msg += `<a href="${
-                    config.blockExplorer
-                }/address/${address}">${returnAddressPreview(address)}</a>`;
-                break;
-            }
-            case '0a': {
-                // 01 - Set profile picture
-                // <url> (1-217 bytes)
-
-                // url is utf8 encoded stack[1]
-                const url = Buffer.from(stackArray[1], 'hex').toString('utf8');
-                // Link to it
-                msg += `<a href="${url}">[img]</a>`;
-                break;
-            }
-            case '0c': {
-                /**
-                 * 0c - Post Topic Message
-                 * <topic_name> (1-214 bytes)
-                 * <message> (1-[214-len(topic_name)] bytes)
-                 */
-
-                // Add the topic
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[1], 'hex').toString('utf8'),
-                );
-
-                // Add a format spacer
-                msg += '|';
-
-                // Add the topic msg
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[2], 'hex').toString('utf8'),
-                );
-                break;
-            }
-            case '10': {
-                /**
-                 * 10 - Create Poll
-                 * <poll_type> (1 byte)
-                 * <option_count> (1 byte)
-                 * <question> (1-209 bytes)
-                 * */
-
-                // You only need the question here
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[3], 'hex').toString('utf8'),
-                );
-
-                break;
-            }
-            case '13': {
-                /**
-                 * 13 Add poll option
-                 * <poll_tx_hash> (32 bytes)
-                 * <option> (1-184 bytes)
-                 */
-
-                // Only parse the option for now
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[2], 'hex').toString('utf8'),
-                );
-
-                break;
-            }
-            case '14': {
-                /**
-                 * 14 - Poll Vote
-                 * <poll_tx_hash> (32 bytes)
-                 * <comment> (0-184 bytes)
-                 */
-
-                // We just want the comment
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[2], 'hex').toString('utf8'),
-                );
-
-                break;
-            }
-            case '20':
-            case '24':
-            case '26': {
-                /**
-                 * 20 - Link request
-                 * 24 - Send money
-                 * 26 - Set address alias
-                 * <address_hash> (20 bytes)
-                 * <message> (1-194 bytes)
-                 */
-
-                // The address is a hex-encoded hash160
-                // all memo addresses are p2pkh
-                const address = cashaddr.encode(
-                    'ecash',
-                    'P2PKH',
-                    stackArray[1],
-                );
-
-                // Link to the address in the msg
-                msg += `<a href="${
-                    config.blockExplorer
-                }/address/${address}">${returnAddressPreview(address)}</a>`;
-
-                // Add a format spacer
-                msg += '|';
-
-                // Add the msg
-                msg += prepareStringForTelegramHTML(
-                    Buffer.from(stackArray[2], 'hex').toString('utf8'),
-                );
-                break;
-            }
-            case '21':
-            case '22':
-            case '30':
-            case '31':
-            case '32':
-            case '35': {
-                /**
-                 * https://github.com/memocash/mips/blob/master/mip-0009/mip-0009.md#specification
-                 *
-                 * These would require additional processing to get info about the specific tokens
-                 * For now, not worth it. Just print the action.
-                 *
-                 * 21 - Link accept
-                 * 22 - Link revoke
-                 * 30 - Sell tokens
-                 * 31 - Token buy offer
-                 * 32 - Attach token sale signature
-                 * 35 - Pin token post
-                 */
-
-                // Remove formatting spacer
-                msg = msg.slice(0, -1);
-                break;
-            }
-
-            default:
-                msg += `Unknown memo action`;
-        }
-        // Test for msgs that are intended for non-XEC audience
-        if (msg.includes('BCH')) {
-            msg = `[check memo.cash for msg]`;
-        }
-        return { app, msg };
-    },
-    /**
-     * Build a msg about an encrypted cashtab msg tx
-     * @param {string} sendingAddress
-     * @param {map} xecReceivingOutputs
-     * @param {object} coingeckoPrices
-     * @returns {string} msg
-     */
-    getEncryptedCashtabMsg: function (
-        sendingAddress,
-        xecReceivingOutputs,
-        totalSatsSent,
-        coingeckoPrices,
-    ) {
-        let displayedSentQtyString = satsToFormattedValue(
-            totalSatsSent,
-            coingeckoPrices,
-        );
-
-        // Remove OP_RETURNs from xecReceivingOutputs
-        let receivingOutputscripts = [];
-        for (const outputScript of xecReceivingOutputs.keys()) {
-            if (!outputScript.startsWith(opReturn.opReturnPrefix)) {
-                receivingOutputscripts.push(outputScript);
-            }
-        }
-
-        let msgRecipientString = `${returnAddressPreview(
-            cashaddr.encodeOutputScript(receivingOutputscripts[0]),
-        )}`;
-        if (receivingOutputscripts.length > 1) {
-            // Subtract 1 because you have already rendered one receiving address
-            msgRecipientString += ` and ${
-                receivingOutputscripts.length - 1
-            } other${receivingOutputscripts.length > 2 ? 's' : ''}`;
-        }
-        return `${returnAddressPreview(
-            sendingAddress,
-        )} sent an encrypted message and ${displayedSentQtyString} to ${msgRecipientString}`;
-    },
-    /**
-     * Parse the stackArray of an airdrop tx to generate a useful telegram msg
-     * @param {array} stackArray
-     * @param {string} airdropSendingAddress
-     * @param {Map} airdropRecipientsMap
-     * @param {object} tokenInfo token info for the swapped token. optional. Bool False if API call failed.
-     * @param {object} coingeckoPrices object containing price info from coingecko. Bool False if API call failed.
-     * @returns {string} msg ready to send through Telegram API
-     */
-    getAirdropTgMsg: function (
-        stackArray,
-        airdropSendingAddress,
-        airdropRecipientsMap,
-        totalSatsAirdropped,
-        tokenInfo,
-        coingeckoPrices,
-    ) {
-        // stackArray for an airdrop tx will be
-        // [airdrop_protocol_identifier, airdropped_tokenId, optional_cashtab_msg_protocol_identifier, optional_cashtab_msg]
-
-        // Validate expected format
-        if (stackArray.length < 2 || stackArray[1].length !== 64) {
-            return `Invalid ${opReturn.knownApps.airdrop.app}`;
-        }
-
-        // get tokenId
-        const tokenId = stackArray[1];
-
-        // Intialize msg with preview of sending address
-        let msg = `${returnAddressPreview(airdropSendingAddress)} airdropped `;
-
-        let displayedAirdroppedQtyString = satsToFormattedValue(
-            totalSatsAirdropped,
-            coingeckoPrices,
-        );
-
-        // Add to msg
-        msg += `${displayedAirdroppedQtyString} to ${airdropRecipientsMap.size} holders of `;
-
-        if (tokenInfo) {
-            // If API call to get tokenInfo was successful to tokenInfo !== false
-            const { tokenTicker } = tokenInfo;
-
-            // Link to token id
-            msg += `<a href="${
-                config.blockExplorer
-            }/tx/${tokenId}">${prepareStringForTelegramHTML(tokenTicker)}</a>`;
-        } else {
-            // Note: tokenInfo is false if the API call to chronik fails
-            // Link to token id
-            msg += `<a href="${config.blockExplorer}/tx/${tokenId}">${
-                tokenId.slice(0, 3) + '...' + tokenId.slice(-3)
-            }</a>`;
-        }
-        // Add Cashtab msg if present
-        if (
-            stackArray.length > 3 &&
-            stackArray[2] === opReturn.knownApps.cashtabMsg.prefix
-        ) {
-            msg += '|';
-            msg += prepareStringForTelegramHTML(
-                Buffer.from(stackArray[3], 'hex').toString('utf8'),
-            );
-        }
-        return msg;
-    },
-    /**
-     * Parse the stackArray of a SWaP tx according to spec to generate a useful telegram msg
-     * @param {array} stackArray
-     * @param {object} tokenInfo token info for the swapped token. optional.
-     * @returns {string} msg ready to send through Telegram API
-     */
-    getSwapTgMsg: function (stackArray, tokenInfo) {
-        // Intialize msg
-        let msg = '';
-
-        // Generic validation to handle possible txs with SWaP protocol identifier but unexpected stack
-        if (stackArray.length < 3) {
-            // If stackArray[1] and stackArray[2] do not exist
-            return 'Invalid SWaP';
-        }
-
-        // SWaP txs are complex. Parse stackArray to build msg.
-        // https://github.com/vinarmani/swap-protocol/blob/master/swap-protocol-spec.md
-
-        // First, get swp_msg_class at stackArray[1]
-        // 01 - A Signal
-        // 02 - A payment
-        const swp_msg_class = stackArray[1];
-
-        // Second , get swp_msg_type at stackArray[2]
-        // 01 - SLP Atomic Swap
-        // 02 - Multi-Party Escrow
-        // 03 - Threshold Crowdfunding
-        const swp_msg_type = stackArray[2];
-
-        // Build msg by class and type
-
-        if (swp_msg_class === '01') {
-            msg += 'Signal';
-            msg += '|';
-            switch (swp_msg_type) {
-                case '01': {
-                    msg += 'SLP Atomic Swap';
-                    msg += '|';
-                    /*
-                    <token_id_bytes> <BUY_or_SELL_ascii> <rate_in_sats_int> 
-                    <proof_of_reserve_int> <exact_utxo_vout_hash_bytes> <exact_utxo_index_int> 
-                    <minimum_sats_to_exchange_int>
-
-                    Note that <rate_in_sats_int> is in hex value in the spec example,
-                    but some examples on chain appear to encode this value in ascii
-                    */
-
-                    if (tokenInfo) {
-                        const { tokenTicker } = tokenInfo;
-
-                        // Link to token id
-                        msg += `<a href="${config.blockExplorer}/tx/${
-                            stackArray[3]
-                        }">${prepareStringForTelegramHTML(tokenTicker)}</a>`;
-                        msg += '|';
-                    } else {
-                        // Note: tokenInfo is false if the API call to chronik fails
-                        // Also false if tokenId is invalid for some reason
-                        // Link to token id if valid
-                        if (
-                            stackArray.length >= 3 &&
-                            stackArray[3].length === 64
-                        ) {
-                            msg += `<a href="${config.blockExplorer}/tx/${stackArray[3]}">Unknown Token</a>`;
-                            msg += '|';
-                        } else {
-                            msg += 'Invalid tokenId|';
-                        }
-                    }
-
-                    // buy or sell?
-                    msg += Buffer.from(stackArray[4], 'hex').toString('ascii');
-
-                    // Add price info if present
-                    // price in XEC, must convert <rate_in_sats_int> from sats to XEC
-                    if (stackArray.length >= 6) {
-                        // In the wild, have seen some SWaP txs use ASCII for encoding rate_in_sats_int
-                        // Make a determination. Spec does not indicate either way, though spec
-                        // example does use hex.
-                        // If stackArray[5] is more than 4 characters long, assume ascii encoding
-                        let rate_in_sats_int;
-                        if (stackArray[5].length > 4) {
-                            rate_in_sats_int = parseInt(
-                                Buffer.from(stackArray[5], 'hex').toString(
-                                    'ascii',
-                                ),
-                            );
-                        } else {
-                            rate_in_sats_int = parseInt(stackArray[5], 16);
-                        }
-
-                        msg += ` for ${(
-                            parseInt(rate_in_sats_int) / 100
-                        ).toLocaleString('en-US', {
-                            maximumFractionDigits: 2,
-                        })} XEC`;
-                    }
-
-                    // Display minimum_sats_to_exchange_int
-                    // Note: sometimes a SWaP tx will not have this info
-                    if (stackArray.length >= 10) {
-                        // In the wild, have seen some SWaP txs use ASCII for encoding minimum_sats_to_exchange_int
-                        // Make a determination. Spec does not indicate either way, though spec
-                        // example does use hex.
-                        // If stackArray[9] is more than 4 characters long, assume ascii encoding
-                        let minimum_sats_to_exchange_int;
-                        if (stackArray[9].length > 4) {
-                            minimum_sats_to_exchange_int = Buffer.from(
-                                stackArray[9],
-                                'hex',
-                            ).toString('ascii');
-                        } else {
-                            minimum_sats_to_exchange_int = parseInt(
-                                stackArray[9],
-                                16,
-                            );
-                        }
-                        msg += '|';
-                        msg += `Min trade: ${(
-                            parseInt(minimum_sats_to_exchange_int) / 100
-                        ).toLocaleString('en-US', {
-                            maximumFractionDigits: 2,
-                        })} XEC`;
-                    }
-                    break;
-                }
-                case '02': {
-                    msg += 'Multi-Party Escrow';
-                    // TODO additional parsing
-                    break;
-                }
-                case '03': {
-                    msg += 'Threshold Crowdfunding';
-                    // TODO additional parsing
-                    break;
-                }
-                default: {
-                    // Malformed SWaP tx
-                    msg += 'Invalid SWaP';
-                    break;
-                }
-            }
-        } else if (swp_msg_class === '02') {
-            msg += 'Payment';
-            msg += '|';
-            switch (swp_msg_type) {
-                case '01': {
-                    msg += 'SLP Atomic Swap';
-                    // TODO additional parsing
-                    break;
-                }
-                case '02': {
-                    msg += 'Multi-Party Escrow';
-                    // TODO additional parsing
-                    break;
-                }
-                case '03': {
-                    msg += 'Threshold Crowdfunding';
-                    // TODO additional parsing
-                    break;
-                }
-                default: {
-                    // Malformed SWaP tx
-                    msg += 'Invalid SWaP';
-                    break;
-                }
-            }
-        } else {
-            // Malformed SWaP tx
-            msg += 'Invalid SWaP';
-        }
-        return msg;
-    },
-    /**
-     * Build a string formatted for Telegram's API using HTML encoding
-     * @param {object} parsedBlock
-     * @param {array or false} coingeckoPrices if no coingecko API error
-     * @param {Map or false} tokenInfoMap if no chronik API error
-     * @param {Map or false} addressInfoMap if no chronik API error
-     * @returns {function} splitOverflowTgMsg(tgMsg)
-     */
-    getBlockTgMessage: function (
-        parsedBlock,
-        coingeckoPrices,
-        tokenInfoMap,
-        outputScriptInfoMap,
-    ) {
-        const { hash, height, miner, staker, numTxs, parsedTxs } = parsedBlock;
-        const { emojis } = config;
-
-        // Define newsworthy types of txs in parsedTxs
-        // These arrays will be used to present txs in batches by type
-        const genesisTxTgMsgLines = [];
-        let cashtabTokenRewards = 0;
-        let cashtabXecRewardTxs = 0;
-        let cashtabXecRewardsTotalXec = 0;
-        const tokenSendTxTgMsgLines = [];
-        const tokenBurnTxTgMsgLines = [];
-        const opReturnTxTgMsgLines = [];
-        let xecSendTxTgMsgLines = [];
-
-        // We do not get that much newsworthy value from a long list of individual token send txs
-        // So, we organize token send txs by tokenId
-        const tokenSendTxMap = new Map();
-
-        // Iterate over parsedTxs to find anything newsworthy
-        for (let i = 0; i < parsedTxs.length; i += 1) {
-            const thisParsedTx = parsedTxs[i];
-            const {
-                txid,
-                genesisInfo,
-                opReturnInfo,
-                txFee,
-                xecSendingOutputScripts,
-                xecReceivingOutputs,
-                tokenSendInfo,
-                tokenBurnInfo,
-                totalSatsSent,
-            } = thisParsedTx;
-
-            if (genesisInfo && tokenInfoMap) {
-                // The txid of a genesis tx is the tokenId
-                const tokenId = txid;
-                const genesisInfoForThisToken = tokenInfoMap.get(tokenId);
-                let { tokenTicker, tokenName, tokenDocumentUrl } =
-                    genesisInfoForThisToken;
-                // Make sure tokenName does not contain telegram html escape characters
-                tokenName = prepareStringForTelegramHTML(tokenName);
-                // Make sure tokenName does not contain telegram html escape characters
-                tokenTicker = prepareStringForTelegramHTML(tokenTicker);
-                // Do not apply this parsing to tokenDocumentUrl, as this could change the URL
-                // If this breaks the msg, so be it
-                // Would only happen for bad URLs
-                genesisTxTgMsgLines.push(
-                    `${emojis.tokenGenesis}<a href="${config.blockExplorer}/tx/${tokenId}">${tokenName}</a> (${tokenTicker}) <a href="${tokenDocumentUrl}">[doc]</a>`,
-                );
-                // This parsed tx has a tg msg line. Move on to the next one.
-                continue;
-            }
-            if (opReturnInfo) {
-                let { app, msg, stackArray, tokenId } = opReturnInfo;
-                let appEmoji = '';
-
-                switch (app) {
-                    case opReturn.memo.app: {
-                        appEmoji = emojis.memo;
-                        break;
-                    }
-                    case opReturn.knownApps.alias.app: {
-                        appEmoji = emojis.alias;
-                        break;
-                    }
-                    case opReturn.knownApps.payButton.app: {
-                        appEmoji = emojis.payButton;
-                        break;
-                    }
-                    case opReturn.knownApps.paywall.app: {
-                        appEmoji = emojis.paywall;
-                        break;
-                    }
-                    case opReturn.knownApps.authentication.app: {
-                        appEmoji = emojis.authentication;
-                        break;
-                    }
-                    case opReturn.knownApps.cashtabMsg.app: {
-                        appEmoji = emojis.cashtabMsg;
-
-                        const displayedSentAmount = satsToFormattedValue(
-                            totalSatsSent,
-                            coingeckoPrices,
-                        );
-
-                        const displayedTxFee = satsToFormattedValue(
-                            txFee,
-                            coingeckoPrices,
-                        );
-
-                        app += `, ${displayedSentAmount} for ${displayedTxFee}`;
-                        break;
-                    }
-                    case opReturn.knownApps.cashtabMsgEncrypted.app: {
-                        msg = module.exports.getEncryptedCashtabMsg(
-                            cashaddr.encodeOutputScript(
-                                xecSendingOutputScripts.values().next().value,
-                            ), // Assume first input is sender
-                            xecReceivingOutputs,
-                            totalSatsSent,
-                            coingeckoPrices,
-                        );
-                        appEmoji = emojis.cashtabEncrypted;
-                        break;
-                    }
-                    case opReturn.knownApps.airdrop.app: {
-                        msg = module.exports.getAirdropTgMsg(
-                            stackArray,
-                            cashaddr.encodeOutputScript(
-                                xecSendingOutputScripts.values().next().value,
-                            ), // Assume first input is sender
-                            xecReceivingOutputs,
-                            totalSatsSent,
-                            tokenId && tokenInfoMap
-                                ? tokenInfoMap.get(tokenId)
-                                : false,
-                            coingeckoPrices,
-                        );
-                        appEmoji = emojis.airdrop;
-                        break;
-                    }
-                    case opReturn.knownApps.swap.app: {
-                        msg = module.exports.getSwapTgMsg(
-                            stackArray,
-                            tokenId && tokenInfoMap
-                                ? tokenInfoMap.get(tokenId)
-                                : false,
-                        );
-                        appEmoji = emojis.swap;
-                        break;
-                    }
-                    case opReturn.knownApps.fusion.app: {
-                        // totalSatsSent is total amount fused
-                        let displayedFusedQtyString = satsToFormattedValue(
-                            totalSatsSent,
-                            coingeckoPrices,
-                        );
-
-                        msg += `Fused ${displayedFusedQtyString} from ${xecSendingOutputScripts.size} inputs into ${xecReceivingOutputs.size} outputs`;
-                        appEmoji = emojis.fusion;
-                        break;
-                    }
-                    default: {
-                        appEmoji = emojis.unknown;
-                        break;
-                    }
-                }
-
-                opReturnTxTgMsgLines.push(
-                    `${appEmoji}<a href="${config.blockExplorer}/tx/${txid}">${app}:</a> ${msg}`,
-                );
-                // This parsed tx has a tg msg line. Move on to the next one.
-                continue;
-            }
-
-            if (tokenSendInfo && tokenInfoMap && !tokenBurnInfo) {
-                // If this is a token send tx that does not burn any tokens and you have tokenInfoMap
-                let { tokenId, tokenChangeOutputs, tokenReceivingOutputs } =
-                    tokenSendInfo;
-
-                // Special handling for Cashtab rewards
-                if (
-                    // CACHET token id
-                    tokenId ===
-                        'aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1' &&
-                    // outputScript of token-server
-                    xecSendingOutputScripts.values().next().value ===
-                        TOKEN_SERVER_OUTPUTSCRIPT
-                ) {
-                    cashtabTokenRewards += 1;
-                    // No further parsing for this tx
-                    continue;
-                }
-
-                // See if you already have info for txs from this token
-                const tokenSendTxInfo = tokenSendTxMap.get(tokenId);
-                if (typeof tokenSendTxInfo === 'undefined') {
-                    // We don't have any other txs for this token, initialize an info object
-                    // Get token info from tokenInfoMap
-                    const thisTokenInfo = tokenInfoMap.get(tokenId);
-
-                    let { tokenTicker, tokenName, decimals } = thisTokenInfo;
-                    // Note: tokenDocumentUrl and tokenDocumentHash are also available from thisTokenInfo
-
-                    // Make sure tokenName does not contain telegram html escape characters
-                    tokenName = prepareStringForTelegramHTML(tokenName);
-                    // Make sure tokenName does not contain telegram html escape characters
-                    tokenTicker = prepareStringForTelegramHTML(tokenTicker);
-
-                    // Initialize token outputs (could be receiving or change depending on tx type)
-                    let tokenOutputs =
-                        tokenReceivingOutputs.size === 0
-                            ? tokenChangeOutputs
-                            : tokenReceivingOutputs;
-
-                    let undecimalizedTokenReceivedAmount = new BigNumber(0);
-                    for (const tokenReceivedAmount of tokenOutputs.values()) {
-                        undecimalizedTokenReceivedAmount =
-                            undecimalizedTokenReceivedAmount.plus(
-                                tokenReceivedAmount,
-                            );
-                    }
-
-                    tokenSendTxMap.set(tokenId, {
-                        sendTxs: 1,
-                        tokenName,
-                        tokenTicker,
-                        decimals,
-                        undecimalizedTokenReceivedAmount,
-                    });
-                } else {
-                    // We do have other txs for this token, increment the tx count and amount sent
-                    // Initialize token outputs (could be receiving or change depending on tx type)
-                    let tokenOutputs =
-                        tokenReceivingOutputs.size === 0
-                            ? tokenChangeOutputs
-                            : tokenReceivingOutputs;
-
-                    let undecimalizedTokenReceivedAmount = new BigNumber(0);
-                    for (const tokenReceivedAmount of tokenOutputs.values()) {
-                        undecimalizedTokenReceivedAmount =
-                            undecimalizedTokenReceivedAmount.plus(
-                                tokenReceivedAmount,
-                            );
-                    }
-
-                    tokenSendTxMap.set(tokenId, {
-                        ...tokenSendTxInfo,
-                        sendTxs: tokenSendTxInfo.sendTxs + 1,
-                        undecimalizedTokenReceivedAmount:
-                            tokenSendTxInfo.undecimalizedTokenReceivedAmount.plus(
-                                undecimalizedTokenReceivedAmount,
-                            ),
-                    });
-                }
-
-                // This parsed tx has info needed to build a tg msg line. Move on to the next one.
-                continue;
-            }
-
-            if (tokenBurnInfo && tokenInfoMap) {
-                // If this is a token burn tx and you have tokenInfoMap
-                const { tokenId, undecimalizedTokenBurnAmount } = tokenBurnInfo;
-
-                if (
-                    typeof tokenId !== 'undefined' &&
-                    tokenInfoMap.has(tokenId)
-                ) {
-                    // Some txs may have tokenBurnInfo, but did not get tokenSendInfo
-                    // e.g. 0bb7e38d7f3968d3c91bba2d7b32273f203bc8b1b486633485f76dc7416a3eca
-                    // This is a token burn tx but it is not indexed as such and requires more sophisticated burn parsing
-                    // So, for now, just parse txs like this as XEC sends
-
-                    // Get token info from tokenInfoMap
-                    const thisTokenInfo = tokenInfoMap.get(tokenId);
-                    let { tokenTicker, decimals } = thisTokenInfo;
-
-                    // Make sure tokenName does not contain telegram html escape characters
-                    tokenTicker = prepareStringForTelegramHTML(tokenTicker);
-
-                    // Calculate true tokenReceivedAmount using decimals
-                    // Use decimals to calculate the burned amount as string
-                    const decimalizedTokenBurnAmount =
-                        bigNumberAmountToLocaleString(
-                            undecimalizedTokenBurnAmount,
-                            decimals,
-                        );
-
-                    const tokenBurningAddressStr = returnAddressPreview(
-                        cashaddr.encodeOutputScript(
-                            xecSendingOutputScripts.values().next().value,
-                        ),
-                    );
-
-                    tokenBurnTxTgMsgLines.push(
-                        `${emojis.tokenBurn}${tokenBurningAddressStr} <a href="${config.blockExplorer}/tx/${txid}">burned</a> ${decimalizedTokenBurnAmount} <a href="${config.blockExplorer}/tx/${tokenId}">${tokenTicker}</a> `,
-                    );
-
-                    // This parsed tx has a tg msg line. Move on to the next one.
-                    continue;
-                }
-            }
-
-            // Txs not parsed above are parsed as xec send txs
-
-            const displayedSentAmount = satsToFormattedValue(
-                totalSatsSent,
-                coingeckoPrices,
-            );
-
-            const displayedTxFee = satsToFormattedValue(txFee, coingeckoPrices);
-
-            // Clone xecReceivingOutputs so that you don't modify unit test mocks
-            let xecReceivingAddressOutputs = new Map(xecReceivingOutputs);
-
-            // Throw out OP_RETURN outputs for txs parsed as XEC send txs
-            xecReceivingAddressOutputs.forEach((value, key, map) => {
-                if (key.startsWith(opReturn.opReturnPrefix)) {
-                    map.delete(key);
-                }
-            });
-
-            // Get address balance emojis for rendered addresses
-            // NB you are using xecReceivingAddressOutputs to avoid OP_RETURN outputScripts
-            let xecSenderEmoji = '';
-            let xecReceiverEmoji = '';
-
-            if (outputScriptInfoMap) {
-                // If you have information about address balances, get balance emojis
-                const firstXecSendingOutputScript = xecSendingOutputScripts
-                    .values()
-                    .next().value;
-
-                if (firstXecSendingOutputScript === TOKEN_SERVER_OUTPUTSCRIPT) {
-                    cashtabXecRewardTxs += 1;
-                    cashtabXecRewardsTotalXec += totalSatsSent;
-                    continue;
-                }
-
-                const firstXecReceivingOutputScript = xecReceivingAddressOutputs
-                    .keys()
-                    .next().value;
-                xecSenderEmoji = outputScriptInfoMap.has(
-                    firstXecSendingOutputScript,
-                )
-                    ? outputScriptInfoMap.get(firstXecSendingOutputScript).emoji
-                    : '';
-                xecReceiverEmoji = outputScriptInfoMap.has(
-                    firstXecReceivingOutputScript,
-                )
-                    ? outputScriptInfoMap.get(firstXecReceivingOutputScript)
-                          .emoji
-                    : '';
-            }
-
-            let xecSendMsg;
-            if (xecReceivingAddressOutputs.size === 0) {
-                // self send tx
-                // In this case, totalSatsSent has already been assigned to changeAmountSats
-
-                xecSendMsg = `${emojis.xecSend}<a href="${
-                    config.blockExplorer
-                }/tx/${txid}">${displayedSentAmount} for ${displayedTxFee}</a>${
-                    xecSenderEmoji !== ''
-                        ? ` ${xecSenderEmoji} ${
-                              xecSendingOutputScripts.size > 1
-                                  ? `${xecSendingOutputScripts.size} addresses`
-                                  : returnAddressPreview(
-                                        cashaddr.encodeOutputScript(
-                                            xecSendingOutputScripts
-                                                .values()
-                                                .next().value,
-                                        ),
-                                    )
-                          } ${config.emojis.arrowRight} ${
-                              xecSendingOutputScripts.size > 1
-                                  ? 'themselves'
-                                  : 'itself'
-                          }`
-                        : ''
-                }`;
-            } else {
-                xecSendMsg = `${emojis.xecSend}<a href="${
-                    config.blockExplorer
-                }/tx/${txid}">${displayedSentAmount} for ${displayedTxFee}</a>${
-                    xecSenderEmoji !== '' || xecReceiverEmoji !== ''
-                        ? ` ${xecSenderEmoji}${returnAddressPreview(
-                              cashaddr.encodeOutputScript(
-                                  xecSendingOutputScripts.values().next().value,
-                              ),
-                          )} ${config.emojis.arrowRight} ${
-                              xecReceivingAddressOutputs.keys().next().value ===
-                              xecSendingOutputScripts.values().next().value
-                                  ? 'itself'
-                                  : `${xecReceiverEmoji}${returnAddressPreview(
-                                        cashaddr.encodeOutputScript(
-                                            xecReceivingAddressOutputs
-                                                .keys()
-                                                .next().value,
-                                        ),
-                                    )}`
-                          }${
-                              xecReceivingAddressOutputs.size > 1
-                                  ? ` and ${
-                                        xecReceivingAddressOutputs.size - 1
-                                    } other${
-                                        xecReceivingAddressOutputs.size - 1 > 1
-                                            ? 's'
-                                            : ''
-                                    }`
-                                  : ''
-                          }`
-                        : ''
-                }`;
-            }
-
-            xecSendTxTgMsgLines.push(xecSendMsg);
-        }
-
-        // Build up message as an array, with each line as an entry
-        let tgMsg = [];
-
-        // Header
-        // <emojis.block><height> | <numTxs> | <miner>
-        tgMsg.push(
-            `${emojis.block}<a href="${
-                config.blockExplorer
-            }/block/${hash}">${height}</a> | ${numTxs} tx${
-                numTxs > 1 ? `s` : ''
-            } | ${miner}`,
-        );
-
-        // Halving countdown
-        const HALVING_HEIGHT = 840000;
-        const blocksLeft = HALVING_HEIGHT - height;
-        if (blocksLeft > 0) {
-            // countdown
-            tgMsg.push(
-                `⏰ ${blocksLeft.toLocaleString('en-US')} block${
-                    blocksLeft !== 1 ? 's' : ''
-                } until eCash halving`,
-            );
-        }
-        if (height === HALVING_HEIGHT) {
-            tgMsg.push(`🎉🎉🎉 eCash block reward reduced by 50% 🎉🎉🎉`);
-        }
-
-        // Staker
-        // Staking rewards to <staker>
-        if (staker) {
-            // Get fiat amount of staking rwds
-            tgMsg.push(
-                `${emojis.staker}${satsToFormattedValue(
-                    staker.reward,
-                    coingeckoPrices,
-                )} to <a href="${config.blockExplorer}/address/${
-                    staker.staker
-                }">${returnAddressPreview(staker.staker)}</a>`,
-            );
-        }
-
-        // Display prices as set in config.js
-        if (coingeckoPrices) {
-            // Iterate over prices and add a line for each price in the object
-
-            for (let i = 0; i < coingeckoPrices.length; i += 1) {
-                const { fiat, ticker, price } = coingeckoPrices[i];
-                const thisFormattedPrice = formatPrice(price, fiat);
-                tgMsg.push(`1 ${ticker} = ${thisFormattedPrice}`);
-            }
-        }
-
-        // Genesis txs
-        if (genesisTxTgMsgLines.length > 0) {
-            // Line break for new section
-            tgMsg.push('');
-
-            // 1 new eToken created:
-            // or
-            // <n> new eTokens created:
-            tgMsg.push(
-                `<b>${genesisTxTgMsgLines.length} new eToken${
-                    genesisTxTgMsgLines.length > 1 ? `s` : ''
-                } created</b>`,
-            );
-
-            tgMsg = tgMsg.concat(genesisTxTgMsgLines);
-        }
-
-        // Cashtab rewards
-        if (cashtabTokenRewards > 0 || cashtabXecRewardTxs > 0) {
-            tgMsg.push('');
-            tgMsg.push(`<a href="https://cashtab.com/">Cashtab</a>`);
-            if (cashtabTokenRewards > 0) {
-                // 1 CACHET reward:
-                // or
-                // <n> CACHET rewards:
-                tgMsg.push(
-                    `<b>${cashtabTokenRewards}</b> <a href="${
-                        config.blockExplorer
-                    }/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">CACHET</a> reward${
-                        cashtabTokenRewards > 1 ? `s` : ''
-                    }`,
-                );
-            }
-
-            // Cashtab XEC rewards
-            if (cashtabXecRewardTxs > 0) {
-                // 1 new user received 42 XEC
-                // or
-                // <n> new users received <...>
-                tgMsg.push(
-                    `<b>${cashtabXecRewardTxs}</b> new user${
-                        cashtabXecRewardTxs > 1 ? `s` : ''
-                    } received <b>${satsToFormattedValue(
-                        cashtabXecRewardsTotalXec,
-                    )}</b>`,
-                );
-            }
-        }
-        if (tokenSendTxMap.size > 0) {
-            // eToken Send txs
-            // Line break for new section
-            tgMsg.push('');
-
-            // We include a 1-line summary for token send txs for each token ID
-            tokenSendTxMap.forEach((tokenSendInfo, tokenId) => {
-                const {
-                    sendTxs,
-                    tokenName,
-                    tokenTicker,
-                    decimals,
-                    undecimalizedTokenReceivedAmount,
-                } = tokenSendInfo;
-
-                // Get decimalized receive amount
-                const decimalizedTokenReceivedAmount =
-                    bigNumberAmountToLocaleString(
-                        undecimalizedTokenReceivedAmount.toString(),
-                        decimals,
-                    );
-
-                tgMsg.push(
-                    `${sendTxs} tx${
-                        sendTxs > 1 ? `s` : ''
-                    } sent ${decimalizedTokenReceivedAmount} <a href="${
-                        config.blockExplorer
-                    }/tx/${tokenId}">${tokenName} (${tokenTicker})</a>`,
-                );
-            });
-
-            tgMsg = tgMsg.concat(tokenSendTxTgMsgLines);
-        }
-
-        // eToken burn txs
-        if (tokenBurnTxTgMsgLines.length > 0) {
-            // Line break for new section
-            tgMsg.push('');
-
-            // 1 eToken burn tx:
-            // or
-            // <n> eToken burn txs:
-            tgMsg.push(
-                `<b>${tokenBurnTxTgMsgLines.length} eToken burn tx${
-                    tokenBurnTxTgMsgLines.length > 1 ? `s` : ''
-                }</b>`,
-            );
-
-            tgMsg = tgMsg.concat(tokenBurnTxTgMsgLines);
-        }
-
-        // OP_RETURN txs
-        if (opReturnTxTgMsgLines.length > 0) {
-            // Line break for new section
-            tgMsg.push('');
-
-            // App txs
-            // or
-            // App tx
-            tgMsg.push(
-                `<b>${opReturnTxTgMsgLines.length} app tx${
-                    opReturnTxTgMsgLines.length > 1 ? `s` : ''
-                }</b>`,
-            );
-
-            // <appName> : <parsedAppData>
-            // alias: newlyregisteredalias
-            // Cashtab Msg: This is a Cashtab Msg
-            tgMsg = tgMsg.concat(opReturnTxTgMsgLines);
-        }
-
-        // XEC txs
-        const totalXecSendCount = xecSendTxTgMsgLines.length;
-        if (totalXecSendCount > 0) {
-            // Line break for new section
-            tgMsg.push('');
-
-            // Don't show more than config-adjustable amount of these txs
-            if (totalXecSendCount > config.xecSendDisplayCount) {
-                xecSendTxTgMsgLines = xecSendTxTgMsgLines.slice(
-                    0,
-                    config.xecSendDisplayCount,
-                );
-                xecSendTxTgMsgLines.push(
-                    `...and <a href="${config.blockExplorer}/block/${hash}">${
-                        totalXecSendCount - config.xecSendDisplayCount
-                    } more</a>`,
-                );
-            }
-            // 1 eCash tx
-            // or
-            // n eCash txs
-            tgMsg.push(
-                `<b>${totalXecSendCount} eCash tx${
-                    totalXecSendCount > 1 ? `s` : ''
-                }</b>`,
-            );
-
-            tgMsg = tgMsg.concat(xecSendTxTgMsgLines);
-        }
-
-        return splitOverflowTgMsg(tgMsg);
-    },
-    /**
-     * Guess the reason why an block was invalidated by avalanche
-     * @param {ChronikClient} chronik
-     * @param {number} blockHeight
-     * @param {object} coinbaseData
-     * @param {object} memoryCache
-     * @returns {string} reason
-     */
-    guessRejectReason: async function (
-        chronik,
-        blockHeight,
-        coinbaseData,
-        memoryCache,
-    ) {
-        // Let's guess the reject reason by looking for the common cases in order:
-        //  1. Missing the miner fund output
-        //  2. Missing the staking reward output
-        //  3. Wrong staking reward winner
-        //  4. Normal orphan (another block exists at the same height)
-        //  5. RTT rejection
-        if (typeof coinbaseData === 'undefined') {
-            return undefined;
-        }
-
-        // 1. Missing the miner fund output
-        // This output is a constant so it's easy to look for
-        let hasMinerFundOuptut = false;
-        for (let i = 0; i < coinbaseData.outputs.length; i += 1) {
-            if (
-                coinbaseData.outputs[i].outputScript === minerFundOutputScript
-            ) {
-                hasMinerFundOuptut = true;
-                break;
-            }
-        }
-        if (!hasMinerFundOuptut) {
-            return 'missing miner fund output';
-        }
-
-        // 2. Missing the staking reward output
-        // We checked for missing miner fund output already, so if there are
-        // fewer than 3 outputs we are sure the staking reward is missing
-        if (coinbaseData.outputs.length < 3) {
-            return 'missing staking reward output';
-        }
-
-        // 3. Wrong staking reward winner
-        const expectedWinner = await memoryCache.get(`${blockHeight}`);
-        // We might have failed to fetch the expected winner for this block, in
-        // which case we can't determine if staking reward is the likely cause.
-        if (typeof expectedWinner !== 'undefined') {
-            const { address, scriptHex } = expectedWinner;
-
-            let stakingRewardOutputIndex = -1;
-            for (let i = 0; i < coinbaseData.outputs.length; i += 1) {
-                if (coinbaseData.outputs[i].outputScript === scriptHex) {
-                    stakingRewardOutputIndex = i;
-                    break;
-                }
-            }
-
-            // We didn't find the expected staking reward output
-            if (stakingRewardOutputIndex < 0) {
-                const wrongWinner = module.exports.getStakerFromCoinbaseTx(
-                    blockHeight,
-                    coinbaseData.outputs,
-                );
-
-                if (wrongWinner !== false) {
-                    // Try to show the eCash address and fallback to script hex
-                    // if it is not possible.
-                    if (typeof address !== 'undefined') {
-                        try {
-                            const wrongWinnerAddress =
-                                cashaddr.encodeOutputScript(wrongWinner.staker);
-                            return `wrong staking reward payout (${wrongWinnerAddress} instead of ${address})`;
-                        } catch (err) {
-                            // Fallthrough
-                        }
-                    }
-
-                    return `wrong staking reward payout (${wrongWinner.staker} instead of ${scriptHex})`;
-                }
-            }
-        }
-
-        // 4. Normal orphan (another block exists at the same height)
-        // If chronik returns a block at the same height, assume it orphaned
-        // the current invalidated block. It's very possible the block is not
-        // finalized yet so we have no better way to check it's actually what
-        // happened.
-        try {
-            const blockAtSameHeight = await chronik.block(blockHeight);
-            return `orphaned by block ${blockAtSameHeight.blockInfo.hash}`;
-        } catch (err) {
-            // Block not found, keep guessing
-        }
-
-        // 5. RTT rejection
-        // FIXME There is currently no way to determine if the block was
-        // rejected due to RTT violation.
-
-        return 'unknown';
-    },
-    /**
-     * Summarize an arbitrary array of chronik txs
-     * Different logic vs "per block" herald msgs, as we are looking to
-     * get meaningful info from more txs
-     * We are interested in what txs were like over a certain time period
-     * Not details of a particular block
-     *
-     * TODO
-     * Biggest tx
-     * Highest fee
-     * Token dex volume
-     * Biggest token sales
-     * Whale alerts
-     *
-     * @param {number} now unix timestamp in seconds
-     * @param {Tx[]} txs array of CONFIRMED Txs
-     * @param {Map} tokenInfoMap tokenId => genesisInfo
-     * @param {object | undefined} priceInfo { usd, usd_market_cap, usd_24h_vol, usd_24h_change }
-     */
-    summarizeTxHistory: function (now, txs, tokenInfoMap, priceInfo) {
-        const xecPriceUsd =
-            typeof priceInfo !== 'undefined' ? priceInfo.usd : undefined;
-        // Throw out any unconfirmed txs
-        txs.filter(tx => tx.block !== 'undefined');
-
-        // Sort by blockheight
-        txs.sort((a, b) => a.block.height - b.block.height);
-        const txCount = txs.length;
-        // Get covered blocks
-        // Note we add 1 as we include the block at index 0
-        const blockCount =
-            txs[txCount - 1].block.height - txs[0].block.height + 1;
-
-        // Initialize objects useful for summarizing data
-
-        // miner => blocks found
-        const minerMap = new Map();
-
-        // miner pools where we can parse individual miners
-        let viaBtcBlocks = 0;
-        const viabtcMinerMap = new Map();
-
-        // stakerOutputScript => {count, reward}
-        const stakerMap = new Map();
-
-        // TODO more info about send txs
-        // inputs[0].outputScript => {count, satoshisSent}
-        // senderMap
-
-        // lokad name => count
-        const appTxMap = new Map();
-
-        let totalStakingRewardSats = 0;
-        let cashtabXecRewardCount = 0;
-        let cashtabXecRewardSats = 0;
-        let cashtabCachetRewardCount = 0;
-        let binanceWithdrawalCount = 0;
-        let binanceWithdrawalSats = 0;
-
-        let slpFungibleTxs = 0;
-        let appTxs = 0;
-        let unknownLokadTxs = 0;
-
-        // tokenId => {info, list, cancel, buy, adPrep, send, burn, mint, genesis: {genesisQty: <>, hasBaton: <>}}
-        const tokenActions = new Map();
-        let invalidTokenEntries = 0;
-        let nftNonAgoraTokenEntries = 0;
-        let mintVaultTokenEntries = 0;
-        let alpTokenEntries = 0;
-
-        let newSlpTokensFixedSupply = 0;
-        let newSlpTokensVariableSupply = 0;
-
-        // Nft vars
-        const nftActions = new Map();
-        const nftAgoraActions = new Map();
-        const uniqueAgoraNfts = new Set();
-        const uniqueNonAgoraNfts = new Set();
-        let agoraOneshotTxs = 0;
-        let nftMints = 0;
-
-        // Agora vars
-        let agoraTxs = 0;
-        const agoraActions = new Map();
-
-        for (const tx of txs) {
-            const { inputs, outputs, block, tokenEntries, isCoinbase } = tx;
-
-            if (isCoinbase) {
-                // Coinbase tx - get miner and staker info
-                const miner = module.exports.getMinerFromCoinbaseTx(
-                    tx.inputs[0].inputScript,
-                    outputs,
-                    miners,
-                );
-                if (miner.includes('ViaBTC')) {
-                    viaBtcBlocks += 1;
-                    // ViaBTC pool miner
-                    let blocksFoundThisViaMiner = viabtcMinerMap.get(miner);
-                    if (typeof blocksFoundThisViaMiner === 'undefined') {
-                        viabtcMinerMap.set(miner, 1);
-                    } else {
-                        viabtcMinerMap.set(miner, blocksFoundThisViaMiner + 1);
-                    }
-                } else {
-                    // Other miner
-                    let blocksFoundThisMiner = minerMap.get(miner);
-                    if (typeof blocksFoundThisMiner === 'undefined') {
-                        minerMap.set(miner, 1);
-                    } else {
-                        minerMap.set(miner, blocksFoundThisMiner + 1);
-                    }
-                }
-
-                const stakerInfo = module.exports.getStakerFromCoinbaseTx(
-                    block.height,
-                    outputs,
-                );
-                if (stakerInfo) {
-                    // The coinbase tx may have no staker
-                    // In thise case, we do not have any staking info to update
-
-                    const { staker, reward } = stakerInfo;
-
-                    totalStakingRewardSats += reward;
-
-                    let stakingRewardsThisStaker = stakerMap.get(staker);
-                    if (typeof stakingRewardsThisStaker === 'undefined') {
-                        stakerMap.set(staker, { count: 1, reward });
-                    } else {
-                        stakingRewardsThisStaker.reward += reward;
-                        stakingRewardsThisStaker.count += 1;
-                    }
-                }
-                // No further analysis for this tx
-                continue;
-            }
-            const senderOutputScript = inputs[0].outputScript;
-            if (senderOutputScript === TOKEN_SERVER_OUTPUTSCRIPT) {
-                // If this tx was sent by token-server
-                if (tokenEntries.length > 0) {
-                    // We assume all token txs sent by token-server are CACHET rewards
-                    // CACHET reward
-                    cashtabCachetRewardCount += 1;
-                } else {
-                    // XEC rwd
-                    cashtabXecRewardCount += 1;
-                    for (const output of outputs) {
-                        const { value, outputScript } = output;
-                        if (outputScript !== TOKEN_SERVER_OUTPUTSCRIPT) {
-                            cashtabXecRewardSats += value;
-                        }
-                    }
-                }
-                // No further analysis for this tx
-                continue;
-            }
-            if (senderOutputScript === BINANCE_OUTPUTSCRIPT) {
-                // Tx sent by Binance
-                // Make sure it's not just a utxo consolidation
-                for (const output of outputs) {
-                    const { value, outputScript } = output;
-                    if (outputScript !== BINANCE_OUTPUTSCRIPT) {
-                        // If we have an output that is not sending to the binance hot wallet
-                        // Increment total value amount withdrawn
-                        binanceWithdrawalSats += value;
-                        // We also call this a withdrawal
-                        // Note that 1 tx from the hot wallet may include more than 1 withdrawal
-                        binanceWithdrawalCount += 1;
-                    }
-                }
-            }
-
-            // Other token actions
-            if (tokenEntries.length > 0) {
-                for (const tokenEntry of tokenEntries) {
-                    // Get the tokenId
-                    // Note that groupTokenId is only defined for NFT child
-                    const {
-                        tokenId,
-                        tokenType,
-                        txType,
-                        groupTokenId,
-                        isInvalid,
-                        actualBurnAmount,
-                    } = tokenEntry;
-                    const { type } = tokenType;
-
-                    if (isInvalid) {
-                        // TODO find this for test tx
-                        invalidTokenEntries += 1;
-                        // Log to console so if we see this tx, we can analyze it for parsing
-                        console.info(
-                            `Unparsed isInvalid tokenEntry in tx: ${tx.txid}`,
-                        );
-                        // No other parsing for this tokenEntry
-                        continue;
-                    }
-
-                    if (type === 'ALP_TOKEN_TYPE_STANDARD') {
-                        // TODO ALP parsing
-                        alpTokenEntries += 1;
-                        // Log to console so if we see this tx, we can analyze it for parsing
-                        console.info(
-                            `Unparsed ALP_TOKEN_TYPE_STANDARD tokenEntry in tx: ${tx.txid}`,
-                        );
-                        // No other parsing for this tokenEntry
-                        continue;
-                    }
-
-                    if (type === 'SLP_TOKEN_TYPE_MINT_VAULT') {
-                        // TODO mint valt parsing
-                        mintVaultTokenEntries += 1;
-                        // Log to console so if we see this tx, we can analyze it for parsing
-                        console.info(
-                            `Unparsed SLP_TOKEN_TYPE_MINT_VAULT tokenEntry in tx: ${tx.txid}`,
-                        );
-                        // No other parsing for this tokenEntry
-                        continue;
-                    }
-
-                    if (type === 'SLP_TOKEN_TYPE_NFT1_CHILD') {
-                        if (typeof groupTokenId === 'undefined') {
-                            // Should never happen
-                            invalidTokenEntries += 1;
-                            // Log to console so if we see this tx, we can analyze it for parsing
-                            console.info(
-                                `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD with undefined groupTokenId: ${tx.txid}`,
-                            );
-                            // No other parsing for this tokenEntry
-                            continue;
-                        }
-                        // Note that we organize all NFT1 children by their collection for herald purposes
-                        // Parse NFT child tx
-
-                        switch (txType) {
-                            case 'NONE': {
-                                invalidTokenEntries += 1;
-                                // Log to console so if we see this tx, we can analyze it for parsing
-                                console.info(
-                                    `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD txType NONE tokenEntry in tx: ${tx.txid}`,
-                                );
-                                // No other parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'UNKNOWN': {
-                                invalidTokenEntries += 1;
-                                // Log to console so if we see this tx, we can analyze it for parsing
-                                console.info(
-                                    `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD txType UNKNOWN tokenEntry in tx: ${tx.txid}`,
-                                );
-                                // No other parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'GENESIS': {
-                                // NFT1 NFTs have special genesis, in that they burn 1 of the group
-                                // their txType is still genesis
-                                // For the herald, these are better represented as "NFT mints" than
-                                // "NFT1 Child Genesis"
-                                // But coding side, we organize them this way
-                                nftMints += 1;
-                                nftNonAgoraTokenEntries += 1;
-
-                                // See if we already have tokenActions at this tokenId
-                                const existingNftActions =
-                                    nftActions.get(groupTokenId);
-                                module.exports.initializeOrIncrementTokenData(
-                                    nftActions,
-                                    existingNftActions,
-                                    groupTokenId,
-                                    'genesis',
-                                );
-                                uniqueNonAgoraNfts.add(tokenId);
-                                // No further parsing for this token entry
-                                continue;
-                            }
-                            case 'SEND': {
-                                // SEND may be Agora ONESHOT or Burn
-                                const existingNftActions =
-                                    nftActions.get(groupTokenId);
-                                const existingNftAgoraActions =
-                                    nftAgoraActions.get(groupTokenId);
-
-                                // For now, we assume that any p2sh token input is agora buy/cancel
-                                // and any p2sh token output is an ad setup tx
-                                // No other known cases of p2sh for token txs on ecash today
-                                // tho multisig is possible, no supporting wallets
-
-                                let isAgoraBuySellList = false;
-                                for (const input of inputs) {
-                                    if (typeof input.token !== 'undefined') {
-                                        const { outputScript, inputScript } =
-                                            input;
-                                        // A token input that is p2sh may be
-                                        // a listing, an ad setup, a buy, or a cancel
-                                        try {
-                                            const { type } =
-                                                cashaddr.getTypeAndHashFromOutputScript(
-                                                    outputScript,
-                                                );
-                                            if (type === 'p2sh') {
-                                                // Note that a ONESHOT agora tx does not necessarily
-                                                // have 0441475230 in the inputscript
-                                                // But we do not have any other p2sh token input txs, so parse
-
-                                                // Agora tx
-                                                // For now, we know all listing txs only have a single p2sh input
-
-                                                if (inputs.length === 1) {
-                                                    // Agora ONESHOT listing in collection groupTokenId
-                                                    module.exports.initializeOrIncrementTokenData(
-                                                        nftAgoraActions,
-                                                        existingNftAgoraActions,
-                                                        groupTokenId,
-                                                        'list',
-                                                    );
-                                                    isAgoraBuySellList = true;
-                                                    // Stop processing inputs for this tx
-                                                    break;
-                                                }
-                                                // Check if this is a cancellation
-                                                // See agora.ts from ecash-agora lib
-                                                // For now, I don't think it makes sense to have an 'isCanceled' method from ecash-agora
-                                                // This is a pretty specific application
-                                                const ops = scriptOps(
-                                                    new Script(
-                                                        fromHex(inputScript),
-                                                    ),
-                                                );
-                                                // isCanceled is always the last pushop (before redeemScript)
-                                                const opIsCanceled =
-                                                    ops[ops.length - 2];
-
-                                                const isCanceled =
-                                                    opIsCanceled === OP_0;
-
-                                                if (isCanceled) {
-                                                    // Agora ONESHOT cancel in collection groupTokenId
-                                                    module.exports.initializeOrIncrementTokenData(
-                                                        nftAgoraActions,
-                                                        existingNftAgoraActions,
-                                                        groupTokenId,
-                                                        'cancel',
-                                                    );
-                                                    isAgoraBuySellList = true;
-                                                    // Stop processing inputs for this tx
-                                                    break;
-                                                } else {
-                                                    // Agora ONESHOT purchase
-                                                    module.exports.initializeOrIncrementTokenData(
-                                                        nftAgoraActions,
-                                                        existingNftAgoraActions,
-                                                        groupTokenId,
-                                                        'buy',
-                                                    );
-                                                    isAgoraBuySellList = true;
-                                                    // Stop processing inputs for this tx
-                                                    break;
-                                                }
-                                            }
-                                        } catch (err) {
-                                            console.error(
-                                                `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) from txid ${tx.txid}`,
-                                            );
-                                            // Do not parse it as an agora tx
-                                        }
-                                        // We don't need to find any other inputs for this case
-                                        break;
-                                    }
-                                }
-                                if (isAgoraBuySellList) {
-                                    agoraOneshotTxs += 1;
-                                    uniqueAgoraNfts.add(tokenId);
-                                    // We have already processed this token tx
-                                    continue;
-                                }
-
-                                // Check for ad prep tx
-                                let isAdPrep = false;
-                                for (const output of outputs) {
-                                    if (typeof output.token !== 'undefined') {
-                                        const { outputScript } = output;
-                                        // We assume a p2sh token output is an ad setup tx
-                                        // No other known use cases at the moment
-                                        try {
-                                            const { type } =
-                                                cashaddr.getTypeAndHashFromOutputScript(
-                                                    outputScript,
-                                                );
-
-                                            if (type === 'p2sh') {
-                                                // Agora ONESHOT ad setup tx for collection groupTokenId
-                                                module.exports.initializeOrIncrementTokenData(
-                                                    nftAgoraActions,
-                                                    existingNftAgoraActions,
-                                                    groupTokenId,
-                                                    'adPrep',
-                                                );
-                                                isAdPrep = true;
-                                                break;
-                                                // Stop iterating over outputs
-                                            }
-                                        } catch (err) {
-                                            console.error(
-                                                `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) for output from txid ${tx.txid}`,
-                                            );
-                                            // Do not parse it as an agora tx
-                                        }
-                                    }
-                                }
-                                if (isAdPrep) {
-                                    agoraOneshotTxs += 1;
-                                    uniqueAgoraNfts.add(tokenId);
-                                    // We have processed this tx as an Agora Ad setup tx
-                                    // No further processing
-                                    continue;
-                                }
-
-                                if (actualBurnAmount !== '0') {
-                                    nftNonAgoraTokenEntries += 1;
-                                    // Parse as burn
-                                    // Note this is not currently supported in Cashtab
-                                    module.exports.initializeOrIncrementTokenData(
-                                        nftActions,
-                                        existingNftActions,
-                                        groupTokenId,
-                                        'burn',
-                                    );
-                                    uniqueNonAgoraNfts.add(tokenId);
-                                    // No further parsing
-                                    continue;
-                                }
-
-                                // Parse as send
-                                module.exports.initializeOrIncrementTokenData(
-                                    nftActions,
-                                    existingNftActions,
-                                    groupTokenId,
-                                    'send',
-                                );
-                                nftNonAgoraTokenEntries += 1;
-                                uniqueNonAgoraNfts.add(tokenId);
-                                // No further parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'MINT': {
-                                // We do not expect to see any MINT txs for NFT1 children
-                                // Some confusion as what crypto colloquially calls an "NFT Mint"
-                                // is NOT this type of mint, but a genesis tx
-                                // Run the map anyway in case we get it
-                                invalidTokenEntries += 1;
-                                // Log to console so if we see this tx, we can analyze it for parsing
-                                console.info(
-                                    `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD txType MINT tokenEntry in tx: ${tx.txid}`,
-                                );
-                                const existingNftActions =
-                                    nftActions.get(groupTokenId);
-                                module.exports.initializeOrIncrementTokenData(
-                                    nftActions,
-                                    existingNftActions,
-                                    tokenId,
-                                    'mint',
-                                );
-                                uniqueNonAgoraNfts.add(tokenId);
-                                // No further parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'BURN': {
-                                const existingNftActions =
-                                    nftActions.get(tokenId);
-                                module.exports.initializeOrIncrementTokenData(
-                                    nftActions,
-                                    existingNftActions,
-                                    tokenId,
-                                    'burn',
-                                );
-                                nftNonAgoraTokenEntries += 1;
-                                uniqueNonAgoraNfts.add(tokenId);
-                                // No further parsing for this tokenEntry
-                                continue;
-                            }
-                            default:
-                                // Can we get here?
-                                // Log for analysis if it happens
-                                invalidTokenEntries += 1;
-                                console.info(
-                                    `Switch default token action for SLP_TOKEN_TYPE_NFT1_CHILD in tx: ${tx.txid}`,
-                                );
-                                // No further analysis this tokenEntry
-                                continue;
-                        }
-                    }
-                    if (type === 'SLP_TOKEN_TYPE_FUNGIBLE') {
-                        slpFungibleTxs += 1;
-                        switch (txType) {
-                            case 'NONE': {
-                                invalidTokenEntries += 1;
-                                // Log to console so if we see this tx, we can analyze it for parsing
-                                console.info(
-                                    `Unparsed SLP_TOKEN_TYPE_FUNGIBLE txType NONE tokenEntry in tx: ${tx.txid}`,
-                                );
-                                // No other parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'UNKNOWN': {
-                                invalidTokenEntries += 1;
-                                // Log to console so if we see this tx, we can analyze it for parsing
-                                console.info(
-                                    `Unparsed SLP_TOKEN_TYPE_FUNGIBLE txType UNKNOWN tokenEntry in tx: ${tx.txid}`,
-                                );
-                                // No other parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'GENESIS': {
-                                const genesis = {
-                                    amount: '0',
-                                    hasBaton: false,
-                                };
-                                // See if we already have tokenActions at this tokenId
-                                const existingActions =
-                                    tokenActions.get(tokenId);
-                                for (const output of outputs) {
-                                    if (typeof output.token !== 'undefined') {
-                                        if (output.token.tokenId === tokenId) {
-                                            // Per spec, SLP 1 genesis qty is always at output index 1
-                                            // But we iterate over all outputs to check for mint batons
-                                            const { amount, isMintBaton } =
-                                                output.token;
-                                            if (isMintBaton) {
-                                                newSlpTokensVariableSupply += 1;
-                                                genesis.hasBaton = true;
-                                            } else {
-                                                newSlpTokensFixedSupply += 1;
-                                                genesis.amount = amount;
-                                            }
-                                        }
-                                        // We do not use initializeOrIncrementTokenData here
-                                        // genesis does not follow the same structure
-                                        // Count is not important but we have more info for genesis
-                                        tokenActions.set(
-                                            tokenId,
-                                            typeof existingActions ===
-                                                'undefined'
-                                                ? { genesis, actionCount: 1 }
-                                                : {
-                                                      ...existingActions,
-                                                      genesis,
-                                                      actionCount:
-                                                          existingActions.actionCount +
-                                                          1,
-                                                  },
-                                        );
-                                        // No further parsing for this tokenEntry
-                                        continue;
-                                    }
-                                }
-                                break;
-                            }
-                            case 'SEND': {
-                                // SEND may be Agora or Burn
-                                const existingTokenActions =
-                                    tokenActions.get(tokenId);
-                                const existingAgoraActions =
-                                    agoraActions.get(tokenId);
-
-                                // For now, we assume that any p2sh token input is agora buy/cancel
-                                // and any p2sh token output is an ad setup tx
-                                // No other known cases of p2sh for token txs on ecash today
-                                // tho multisig is possible, no supporting wallets
-
-                                // mb parse for ad setup first, which is p2sh output?
-
-                                let isAgoraBuySellList = false;
-                                for (const input of inputs) {
-                                    if (typeof input.token !== 'undefined') {
-                                        const { outputScript, inputScript } =
-                                            input;
-                                        // A token input that is p2sh may be
-                                        // a listing, an ad setup, a buy, or a cancel
-                                        try {
-                                            const { type } =
-                                                cashaddr.getTypeAndHashFromOutputScript(
-                                                    outputScript,
-                                                );
-                                            if (type === 'p2sh') {
-                                                // We are only parsing SLP agora txs here
-                                                // A listing will have AGR0 lokad in input script
-                                                const AGORA_LOKAD_STARTSWITH =
-                                                    '0441475230';
-
-                                                if (
-                                                    inputScript.startsWith(
-                                                        AGORA_LOKAD_STARTSWITH,
-                                                    )
-                                                ) {
-                                                    // Agora tx
-                                                    // For now, we know all listing txs only have a single p2sh input
-
-                                                    if (inputs.length === 1) {
-                                                        // Agora listing
-                                                        module.exports.initializeOrIncrementTokenData(
-                                                            agoraActions,
-                                                            existingAgoraActions,
-                                                            tokenId,
-                                                            'list',
-                                                        );
-                                                        isAgoraBuySellList = true;
-                                                        // Stop processing inputs for this tx
-                                                        break;
-                                                    }
-                                                    // Check if this is a cancellation
-                                                    // See agora.ts from ecash-agora lib
-                                                    // For now, I don't think it makes sense to have an 'isCanceled' method from ecash-agora
-                                                    // This is a pretty specific application
-                                                    const ops = scriptOps(
-                                                        new Script(
-                                                            fromHex(
-                                                                inputScript,
-                                                            ),
-                                                        ),
-                                                    );
-                                                    // isCanceled is always the last pushop (before redeemScript)
-                                                    const opIsCanceled =
-                                                        ops[ops.length - 2];
-
-                                                    const isCanceled =
-                                                        opIsCanceled === OP_0;
-
-                                                    if (isCanceled) {
-                                                        // Agora cancel
-                                                        module.exports.initializeOrIncrementTokenData(
-                                                            agoraActions,
-                                                            existingAgoraActions,
-                                                            tokenId,
-                                                            'cancel',
-                                                        );
-                                                        isAgoraBuySellList = true;
-                                                        // Stop processing inputs for this tx
-                                                        break;
-                                                    } else {
-                                                        // Agora purchase
-                                                        module.exports.initializeOrIncrementTokenData(
-                                                            agoraActions,
-                                                            existingAgoraActions,
-                                                            tokenId,
-                                                            'buy',
-                                                        );
-                                                        isAgoraBuySellList = true;
-                                                        // Stop processing inputs for this tx
-                                                        break;
-                                                    }
-                                                }
-                                            }
-                                        } catch (err) {
-                                            console.error(
-                                                `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) from txid ${tx.txid}`,
-                                            );
-                                            // Do not parse it as an agora tx
-                                        }
-                                        // We don't need to find any other inputs for this case
-                                        break;
-                                    }
-                                }
-                                if (isAgoraBuySellList) {
-                                    agoraTxs += 1;
-                                    // We have already processed this token tx
-                                    continue;
-                                }
-
-                                // Check for ad prep tx
-                                let isAdPrep = false;
-                                for (const output of outputs) {
-                                    if (typeof output.token !== 'undefined') {
-                                        const { outputScript } = output;
-                                        // We assume a p2sh token output is an ad setup tx
-                                        // No other known use cases at the moment
-                                        try {
-                                            const { type } =
-                                                cashaddr.getTypeAndHashFromOutputScript(
-                                                    outputScript,
-                                                );
-                                            if (type === 'p2sh') {
-                                                // Agora ad setup tx for SLP1
-                                                module.exports.initializeOrIncrementTokenData(
-                                                    agoraActions,
-                                                    existingAgoraActions,
-                                                    tokenId,
-                                                    'adPrep',
-                                                );
-                                                isAdPrep = true;
-                                                break;
-                                                // Stop iterating over outputs
-                                            }
-                                        } catch (err) {
-                                            console.error(
-                                                `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) for output from txid ${tx.txid}`,
-                                            );
-                                            // Do not parse it as an agora tx
-                                        }
-                                    }
-                                }
-                                if (isAdPrep) {
-                                    agoraTxs += 1;
-                                    // We have processed this tx as an Agora Ad setup tx
-                                    // No further processing
-                                    continue;
-                                }
-
-                                // Parse as burn
-                                if (actualBurnAmount !== '0') {
-                                    module.exports.initializeOrIncrementTokenData(
-                                        tokenActions,
-                                        existingTokenActions,
-                                        tokenId,
-                                        'burn',
-                                    );
-                                    // No further parsing
-                                    continue;
-                                }
-
-                                // Parse as send
-                                module.exports.initializeOrIncrementTokenData(
-                                    tokenActions,
-                                    existingTokenActions,
-                                    tokenId,
-                                    'send',
-                                );
-                                // No further parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'MINT': {
-                                const existingTokenActions =
-                                    tokenActions.get(tokenId);
-                                module.exports.initializeOrIncrementTokenData(
-                                    tokenActions,
-                                    existingTokenActions,
-                                    tokenId,
-                                    'mint',
-                                );
-                                // No further parsing for this tokenEntry
-                                continue;
-                            }
-                            case 'BURN': {
-                                const existingTokenActions =
-                                    tokenActions.get(tokenId);
-                                module.exports.initializeOrIncrementTokenData(
-                                    tokenActions,
-                                    existingTokenActions,
-                                    tokenId,
-                                    'burn',
-                                );
-                                // No further parsing for this tokenEntry
-                                continue;
-                            }
-                            default:
-                                // Can we get here?
-                                // Log for analysis if it happens
-                                invalidTokenEntries += 1;
-                                console.info(
-                                    `Switch default token action in tx: ${tx.txid}`,
-                                );
-                                // No further analysis this tokenEntry
-                                continue;
-                        }
-                    }
-                }
-
-                // No further action this tx
-                continue;
-            }
-            const firstOutputScript = outputs[0].outputScript;
-            const LOKAD_OPRETURN_STARTSWITH = '6a04';
-            if (firstOutputScript.startsWith(LOKAD_OPRETURN_STARTSWITH)) {
-                appTxs += 1;
-                // We only parse minimally-pushed lokad ids
-
-                // Get the lokadId (the 4-byte first push)
-                const lokadId = firstOutputScript.slice(4, 12);
-
-                // Add to map
-                const countThisLokad = appTxMap.get(lokadId);
-                appTxMap.set(
-                    lokadId,
-                    typeof countThisLokad === 'undefined'
-                        ? 1
-                        : countThisLokad + 1,
-                );
-            }
-        }
-
-        // Add ViaBTC as a single entity to minerMap
-        minerMap.set(`ViaBTC`, viaBtcBlocks);
-        // Sort miner map by blocks found
-        const sortedMinerMap = new Map(
-            [...minerMap.entries()].sort(
-                (keyValueArrayA, keyValueArrayB) =>
-                    keyValueArrayB[1] - keyValueArrayA[1],
-            ),
-        );
-        const sortedStakerMap = new Map(
-            [...stakerMap.entries()].sort(
-                (keyValueArrayA, keyValueArrayB) =>
-                    keyValueArrayB[1].count - keyValueArrayA[1].count,
-            ),
-        );
-
-        // Build your msg
-        const tgMsg = [];
-
-        tgMsg.push(
-            `<b>${new Date(now * 1000).toLocaleDateString('en-GB', {
-                year: 'numeric',
-                month: 'short',
-                day: 'numeric',
-                timeZone: 'UTC',
-            })}</b>`,
-        );
-        tgMsg.push(
-            `${config.emojis.block}${blockCount.toLocaleString(
-                'en-US',
-            )} blocks`,
-        );
-        tgMsg.push(
-            `${config.emojis.arrowRight}${txs.length.toLocaleString(
-                'en-US',
-            )} txs`,
-        );
-        tgMsg.push('');
-
-        // Market summary
-        if (typeof priceInfo !== 'undefined') {
-            const { usd_market_cap, usd_24h_vol, usd_24h_change } = priceInfo;
-            tgMsg.push(
-                `${
-                    usd_24h_change > 0
-                        ? config.emojis.priceUp
-                        : config.emojis.priceDown
-                }<b>1 XEC = ${formatPrice(
-                    xecPriceUsd,
-                    'usd',
-                )}</b> <i>(${usd_24h_change.toFixed(2)}%)</i>`,
-            );
-            tgMsg.push(
-                `Trading volume: $${usd_24h_vol.toLocaleString('en-US', {
-                    maximumFractionDigits: 0,
-                })}`,
-            );
-            tgMsg.push(
-                `Market cap: $${usd_market_cap.toLocaleString('en-US', {
-                    maximumFractionDigits: 0,
-                })}`,
-            );
-            tgMsg.push('');
-        }
-
-        // Top miners
-        const MINERS_TO_SHOW = 3;
-        tgMsg.push(
-            `<b><i>${config.emojis.miner}${sortedMinerMap.size} miners found blocks</i></b>`,
-        );
-        tgMsg.push(`<u>Top ${MINERS_TO_SHOW}</u>`);
-
-        const topMiners = [...sortedMinerMap.entries()].slice(
-            0,
-            MINERS_TO_SHOW,
-        );
-        for (let i = 0; i < topMiners.length; i += 1) {
-            const count = topMiners[i][1];
-            const pct = (100 * (count / blockCount)).toFixed(0);
-            tgMsg.push(
-                `${i + 1}. ${topMiners[i][0]}, ${count} <i>(${pct}%)</i>`,
-            );
-        }
-        tgMsg.push('');
-
-        const SATOSHIS_PER_XEC = 100;
-        const totalStakingRewardsXec =
-            totalStakingRewardSats / SATOSHIS_PER_XEC;
-        const renderedTotalStakingRewards =
-            typeof xecPriceUsd !== 'undefined'
-                ? `$${(totalStakingRewardsXec * xecPriceUsd).toLocaleString(
-                      'en-US',
-                      {
-                          minimumFractionDigits: 0,
-                          maximumFractionDigits: 0,
-                      },
-                  )}`
-                : `${totalStakingRewardsXec.toLocaleString('en-US', {
-                      minimumFractionDigits: 0,
-                      maximumFractionDigits: 0,
-                  })} XEC`;
-
-        // Top stakers
-        const STAKERS_TO_SHOW = 3;
-        tgMsg.push(
-            `<b><i>${config.emojis.staker}${sortedStakerMap.size} stakers earned ${renderedTotalStakingRewards}</i></b>`,
-        );
-        tgMsg.push(`<u>Top ${STAKERS_TO_SHOW}</u>`);
-        const topStakers = [...sortedStakerMap.entries()].slice(
-            0,
-            STAKERS_TO_SHOW,
-        );
-        for (let i = 0; i < topStakers.length; i += 1) {
-            const staker = topStakers[i];
-            const count = staker[1].count;
-            const pct = (100 * (count / blockCount)).toFixed(0);
-            const addr = cashaddr.encodeOutputScript(staker[0]);
-            tgMsg.push(
-                `${i + 1}. ${`<a href="${
-                    config.blockExplorer
-                }/address/${addr}">${returnAddressPreview(addr)}</a>`}, ${
-                    staker[1].count
-                } <i>(${pct}%)</i>`,
-            );
-        }
-
-        // Tx breakdown
-
-        // Cashtab rewards
-        if (cashtabXecRewardCount > 0 || cashtabCachetRewardCount > 0) {
-            tgMsg.push('');
-            tgMsg.push(`<a href="https://cashtab.com/">Cashtab</a>`);
-            // Cashtab XEC rewards
-            if (cashtabXecRewardCount > 0) {
-                // 1 new user received 42 XEC
-                // or
-                // <n> new users received <...>
-                tgMsg.push(
-                    `${
-                        config.emojis.gift
-                    } <b>${cashtabXecRewardCount}</b> new user${
-                        cashtabXecRewardCount > 1 ? `s` : ''
-                    } received <b>${satsToFormattedValue(
-                        cashtabXecRewardSats,
-                    )}</b>`,
-                );
-            }
-            if (cashtabCachetRewardCount > 0) {
-                // 1 CACHET reward:
-                // or
-                // <n> CACHET rewards:
-                tgMsg.push(
-                    `${
-                        config.emojis.tokenSend
-                    } <b>${cashtabCachetRewardCount}</b> <a href="${
-                        config.blockExplorer
-                    }/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">CACHET</a> reward${
-                        cashtabCachetRewardCount > 1 ? `s` : ''
-                    }`,
-                );
-            }
-            tgMsg.push('');
-        }
-
-        // Agora partials
-        if (agoraTxs > 0) {
-            // Zero out counters for sorting purposes
-            agoraActions.forEach((agoraActionInfo, tokenId) => {
-                // Note we do not check adPrep as any token with adPrep has listing
-                const { buy, list, cancel } = agoraActionInfo;
-
-                if (typeof buy === 'undefined') {
-                    agoraActionInfo.buy = { count: 0 };
-                }
-                if (typeof list === 'undefined') {
-                    agoraActionInfo.list = { count: 0 };
-                }
-                if (typeof cancel === 'undefined') {
-                    agoraActionInfo.cancel = { count: 0 };
-                }
-                agoraActions.set(tokenId, agoraActionInfo);
-            });
-
-            // Sort agoraActions by buys
-            const sortedAgoraActions = new Map(
-                [...agoraActions.entries()].sort(
-                    (keyValueArrayA, keyValueArrayB) =>
-                        keyValueArrayB[1].buy.count -
-                        keyValueArrayA[1].buy.count,
-                ),
-            );
-
-            const agoraTokens = Array.from(sortedAgoraActions.keys());
-            const agoraTokenCount = agoraTokens.length;
-
-            tgMsg.push(
-                `${config.emojis.agora}${
-                    config.emojis.token
-                } <b><i>${agoraTxs.toLocaleString('en-US')} Agora token tx${
-                    agoraTxs > 1 ? 's' : ''
-                } from ${agoraTokenCount} token${
-                    agoraTokenCount > 1 ? 's' : ''
-                }</i></b>`,
-            );
-
-            const AGORA_TOKENS_TO_SHOW = 10;
-
-            // Handle case where we do not see as many agora tokens as our max
-            const agoraTokensToShow =
-                agoraTokenCount < AGORA_TOKENS_TO_SHOW
-                    ? agoraTokenCount
-                    : AGORA_TOKENS_TO_SHOW;
-            const newsworthyAgoraTokens = agoraTokens.slice(
-                0,
-                agoraTokensToShow,
-            );
-
-            if (agoraTokenCount > AGORA_TOKENS_TO_SHOW) {
-                tgMsg.push(`<u>Top ${AGORA_TOKENS_TO_SHOW}</u>`);
-            }
-
-            // Emoji key
-            tgMsg.push(
-                `${config.emojis.agoraBuy}Buy, ${config.emojis.agoraList}List, ${config.emojis.agoraCancel}Cancel`,
-            );
-
-            for (let i = 0; i < newsworthyAgoraTokens.length; i += 1) {
-                const tokenId = newsworthyAgoraTokens[i];
-                const tokenActionInfo = sortedAgoraActions.get(tokenId);
-                const genesisInfo = tokenInfoMap.get(tokenId);
-
-                const { buy, list, cancel } = tokenActionInfo;
-
-                tgMsg.push(
-                    `<a href="${config.blockExplorer}/tx/${tokenId}">${
-                        typeof genesisInfo === 'undefined'
-                            ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
-                            : genesisInfo.tokenName
-                    }</a>${
-                        typeof genesisInfo === 'undefined'
-                            ? ''
-                            : genesisInfo.tokenTicker !== ''
-                            ? ` (${genesisInfo.tokenTicker})`
-                            : ''
-                    }: ${
-                        buy.count > 0
-                            ? `${config.emojis.agoraBuy}${
-                                  buy.count > 1 ? `x${buy.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        list.count > 0
-                            ? `${config.emojis.agoraList}${
-                                  list.count > 1 ? `x${list.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        cancel.count > 0
-                            ? `${config.emojis.agoraCancel}${
-                                  cancel.count > 1 ? `x${cancel.count}` : ''
-                              }`
-                            : ''
-                    }`,
-                );
-            }
-            // Newline after agora section
-            tgMsg.push('');
-        }
-        // Agora ONESHOT (NFTs)
-        if (agoraOneshotTxs > 0) {
-            // Zero out counters for sorting purposes
-            nftAgoraActions.forEach((agoraActionInfo, tokenId) => {
-                // Note we do not check adPrep as any token with adPrep has listing
-                const { buy, list, cancel } = agoraActionInfo;
-
-                if (typeof buy === 'undefined') {
-                    agoraActionInfo.buy = { count: 0 };
-                }
-                if (typeof list === 'undefined') {
-                    agoraActionInfo.list = { count: 0 };
-                }
-                if (typeof cancel === 'undefined') {
-                    agoraActionInfo.cancel = { count: 0 };
-                }
-                nftAgoraActions.set(tokenId, agoraActionInfo);
-            });
-
-            // Sort agoraActions by buys
-            const sortedNftAgoraActions = new Map(
-                [...nftAgoraActions.entries()].sort(
-                    (keyValueArrayA, keyValueArrayB) =>
-                        keyValueArrayB[1].buy.count -
-                        keyValueArrayA[1].buy.count,
-                ),
-            );
-
-            const agoraNftCollections = Array.from(
-                sortedNftAgoraActions.keys(),
-            );
-
-            const agoraCollectionCount = agoraNftCollections.length;
-            const agoraNftCount = uniqueAgoraNfts.size;
-
-            tgMsg.push(
-                `${config.emojis.agora}${
-                    config.emojis.nft
-                } <b><i>${agoraOneshotTxs.toLocaleString(
-                    'en-US',
-                )} Agora NFT tx${
-                    agoraTxs > 1 ? 's' : ''
-                } from ${agoraNftCount} NFT${
-                    agoraNftCount > 1 ? 's' : ''
-                } in ${agoraCollectionCount} collection${
-                    agoraCollectionCount > 1 ? 's' : ''
-                }</i></b>`,
-            );
-
-            const AGORA_COLLECTIONS_TO_SHOW = 10;
-
-            // Handle case where we do not see as many agora tokens as our max
-            const agoraCollectionsToShow =
-                agoraCollectionCount < AGORA_COLLECTIONS_TO_SHOW
-                    ? agoraCollectionCount
-                    : AGORA_COLLECTIONS_TO_SHOW;
-            const newsworthyAgoraCollections = agoraNftCollections.slice(
-                0,
-                agoraCollectionsToShow,
-            );
-
-            if (agoraCollectionCount > AGORA_COLLECTIONS_TO_SHOW) {
-                tgMsg.push(`<u>Top ${AGORA_COLLECTIONS_TO_SHOW}</u>`);
-            }
-
-            // Repeat emoji key
-            tgMsg.push(
-                `${config.emojis.agoraBuy}Buy, ${config.emojis.agoraList}List, ${config.emojis.agoraCancel}Cancel`,
-            );
-
-            for (let i = 0; i < newsworthyAgoraCollections.length; i += 1) {
-                const tokenId = newsworthyAgoraCollections[i];
-                const tokenActionInfo = sortedNftAgoraActions.get(tokenId);
-                const genesisInfo = tokenInfoMap.get(tokenId);
-
-                const { buy, list, cancel } = tokenActionInfo;
-
-                tgMsg.push(
-                    `<a href="${config.blockExplorer}/tx/${tokenId}">${
-                        typeof genesisInfo === 'undefined'
-                            ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
-                            : genesisInfo.tokenName
-                    }</a>${
-                        typeof genesisInfo === 'undefined'
-                            ? ''
-                            : genesisInfo.tokenTicker !== ''
-                            ? ` (${genesisInfo.tokenTicker})`
-                            : ''
-                    }: ${
-                        buy.count > 0
-                            ? `${config.emojis.agoraBuy}${
-                                  buy.count > 1 ? `x${buy.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        list.count > 0
-                            ? `${config.emojis.agoraList}${
-                                  list.count > 1 ? `x${list.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        cancel.count > 0
-                            ? `${config.emojis.agoraCancel}${
-                                  cancel.count > 1 ? `x${cancel.count}` : ''
-                              }`
-                            : ''
-                    }`,
-                );
-            }
-            // Newline after agora section
-            tgMsg.push('');
-        }
-
-        // SLP 1 fungible summary
-        if (slpFungibleTxs > 0) {
-            // Sort tokenActions map by number of token actions
-            const sortedTokenActions = new Map(
-                [...tokenActions.entries()].sort(
-                    (keyValueArrayA, keyValueArrayB) =>
-                        keyValueArrayB[1].actionCount -
-                        keyValueArrayA[1].actionCount,
-                ),
-            );
-
-            // nonAgoraTokens will probably include tokens with agora actions
-            // It's just that we want to present how many tokens had non-agora actions
-            const nonAgoraTokens = Array.from(sortedTokenActions.keys());
-
-            const nonAgoraTokenCount = nonAgoraTokens.length;
-            tgMsg.push(
-                `${config.emojis.token} <b><i>${slpFungibleTxs.toLocaleString(
-                    'en-US',
-                )} token tx${
-                    slpFungibleTxs > 1 ? 's' : ''
-                } from ${nonAgoraTokenCount} token${
-                    nonAgoraTokenCount > 1 ? 's' : ''
-                }</i></b>`,
-            );
-
-            const NON_AGORA_TOKENS_TO_SHOW = 5;
-            const nonAgoraTokensToShow =
-                nonAgoraTokenCount < NON_AGORA_TOKENS_TO_SHOW
-                    ? nonAgoraTokenCount
-                    : NON_AGORA_TOKENS_TO_SHOW;
-            const newsworthyTokens = nonAgoraTokens.slice(
-                0,
-                nonAgoraTokensToShow,
-            );
-
-            if (nonAgoraTokenCount > NON_AGORA_TOKENS_TO_SHOW) {
-                tgMsg.push(`<u>Top ${NON_AGORA_TOKENS_TO_SHOW}</u>`);
-            }
-
-            for (let i = 0; i < newsworthyTokens.length; i += 1) {
-                const tokenId = newsworthyTokens[i];
-                const tokenActionInfo = sortedTokenActions.get(tokenId);
-                const genesisInfo = tokenInfoMap.get(tokenId);
-
-                const { send, genesis, burn, mint } = tokenActionInfo;
-
-                tgMsg.push(
-                    `<a href="${config.blockExplorer}/tx/${tokenId}">${
-                        typeof genesisInfo === 'undefined'
-                            ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
-                            : genesisInfo.tokenName
-                    }</a>${
-                        typeof genesisInfo === 'undefined'
-                            ? ''
-                            : genesisInfo.tokenTicker !== ''
-                            ? ` (${genesisInfo.tokenTicker})`
-                            : ''
-                    }: ${
-                        typeof genesis !== 'undefined'
-                            ? config.emojis.tokenGenesis
-                            : ''
-                    }${
-                        typeof send !== 'undefined'
-                            ? `${config.emojis.arrowRight}${
-                                  send.count > 1 ? `x${send.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        typeof burn !== 'undefined'
-                            ? `${config.emojis.tokenBurn}${
-                                  burn.count > 1 ? `x${burn.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        typeof mint !== 'undefined'
-                            ? `${config.emojis.tokenMint}${
-                                  mint.count > 1 ? `x${mint.count}` : ''
-                              }`
-                            : ''
-                    }`,
-                );
-            }
-
-            // Line break for new section
-            tgMsg.push('');
-        }
-
-        // NFT summary
-        if (nftNonAgoraTokenEntries > 0) {
-            // Sort tokenActions map by number of token actions
-            const sortedNftActions = new Map(
-                [...nftActions.entries()].sort(
-                    (keyValueArrayA, keyValueArrayB) =>
-                        keyValueArrayB[1].actionCount -
-                        keyValueArrayA[1].actionCount,
-                ),
-            );
-            const collectionsWithNonAgoraActions = Array.from(
-                sortedNftActions.keys(),
-            );
-            const collectionsWithNonAgoraActionsCount =
-                collectionsWithNonAgoraActions.length;
-
-            // Note that uniqueNonAgoraNfts and uniqueAgoraNfts can have some of the same members
-            // Some NFTs will have both agora actions and non-agora actions
-
-            tgMsg.push(
-                `${
-                    config.emojis.nft
-                } <b><i>${nftNonAgoraTokenEntries.toLocaleString(
-                    'en-US',
-                )} NFT tx${nftNonAgoraTokenEntries > 1 ? 's' : ''} from ${
-                    uniqueNonAgoraNfts.size
-                } NFT${
-                    uniqueNonAgoraNfts.size > 1 ? 's' : ''
-                } in ${collectionsWithNonAgoraActionsCount} collection${
-                    collectionsWithNonAgoraActionsCount > 1 ? 's' : ''
-                }</i></b>`,
-            );
-
-            const NON_AGORA_COLLECTIONS_TO_SHOW = 5;
-            const nonAgoraCollectionsToShow =
-                collectionsWithNonAgoraActionsCount <
-                NON_AGORA_COLLECTIONS_TO_SHOW
-                    ? collectionsWithNonAgoraActionsCount
-                    : NON_AGORA_COLLECTIONS_TO_SHOW;
-            const newsworthyCollections = collectionsWithNonAgoraActions.slice(
-                0,
-                nonAgoraCollectionsToShow,
-            );
-
-            if (
-                collectionsWithNonAgoraActionsCount >
-                NON_AGORA_COLLECTIONS_TO_SHOW
-            ) {
-                tgMsg.push(`<u>Top ${NON_AGORA_COLLECTIONS_TO_SHOW}</u>`);
-            }
-
-            for (let i = 0; i < newsworthyCollections.length; i += 1) {
-                const tokenId = newsworthyCollections[i];
-                const tokenActionInfo = sortedNftActions.get(tokenId);
-                const genesisInfo = tokenInfoMap.get(tokenId);
-
-                const { send, genesis, burn, mint } = tokenActionInfo;
-
-                tgMsg.push(
-                    `<a href="${config.blockExplorer}/tx/${tokenId}">${
-                        typeof genesisInfo === 'undefined'
-                            ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
-                            : genesisInfo.tokenName
-                    }</a>${
-                        typeof genesisInfo === 'undefined'
-                            ? ''
-                            : genesisInfo.tokenTicker !== ''
-                            ? ` (${genesisInfo.tokenTicker})`
-                            : ''
-                    }: ${
-                        typeof genesis !== 'undefined'
-                            ? `${config.emojis.tokenGenesis}${
-                                  genesis.count > 1 ? `x${genesis.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        typeof send !== 'undefined'
-                            ? `${config.emojis.arrowRight}${
-                                  send.count > 1 ? `x${send.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        typeof burn !== 'undefined'
-                            ? `${config.emojis.tokenBurn}${
-                                  burn.count > 1 ? `x${burn.count}` : ''
-                              }`
-                            : ''
-                    }${
-                        typeof mint !== 'undefined'
-                            ? `${config.emojis.tokenMint}${
-                                  mint.count > 1 ? `x${mint.count}` : ''
-                              }`
-                            : ''
-                    }`,
-                );
-            }
-            // Line break for new section
-            tgMsg.push('');
-        }
-
-        // Genesis and mints token summary
-        const unparsedTokenEntries =
-            alpTokenEntries > 0 ||
-            mintVaultTokenEntries > 0 ||
-            invalidTokenEntries > 0;
-        const hasTokenSummaryLines =
-            nftMints > 0 ||
-            newSlpTokensFixedSupply > 0 ||
-            newSlpTokensVariableSupply > 0 ||
-            unparsedTokenEntries;
-        if (nftMints > 0) {
-            tgMsg.push(
-                `${config.emojis.nft} <b><i>${nftMints} NFT mint${
-                    nftMints > 1 ? 's' : ''
-                }</i></b>`,
-            );
-        }
-        if (newSlpTokensFixedSupply > 0) {
-            tgMsg.push(
-                `${
-                    config.emojis.tokenFixed
-                } <b><i>${newSlpTokensFixedSupply} new fixed-supply token${
-                    newSlpTokensFixedSupply > 1 ? 's' : ''
-                }</i></b>`,
-            );
-        }
-        if (newSlpTokensVariableSupply > 0) {
-            tgMsg.push(
-                `${
-                    config.emojis.tokenMint
-                } <b><i>${newSlpTokensVariableSupply} new variable-supply token${
-                    newSlpTokensVariableSupply > 1 ? 's' : ''
-                }</i></b>`,
-            );
-        }
-
-        // Unparsed token summary
-        if (alpTokenEntries > 0) {
-            tgMsg.push(
-                `${config.emojis.alp} <b><i>${alpTokenEntries.toLocaleString(
-                    'en-US',
-                )} ALP tx${alpTokenEntries > 1 ? 's' : ''}</i></b>`,
-            );
-        }
-        if (mintVaultTokenEntries > 0) {
-            tgMsg.push(
-                `${
-                    config.emojis.mintvault
-                } <b><i>${mintVaultTokenEntries.toLocaleString(
-                    'en-US',
-                )} Mint Vault tx${
-                    mintVaultTokenEntries > 1 ? 's' : ''
-                }</i></b>`,
-            );
-        }
-        if (invalidTokenEntries > 0) {
-            tgMsg.push(
-                `${
-                    config.emojis.invalid
-                } <b><i>${invalidTokenEntries.toLocaleString(
-                    'en-US',
-                )} invalid token tx${
-                    invalidTokenEntries > 1 ? 's' : ''
-                }</i></b>`,
-            );
-        }
-        if (hasTokenSummaryLines) {
-            tgMsg.push('');
-        }
-        if (appTxs > 0) {
-            // Sort appTxMap by most common app txs
-            const sortedAppTxMap = new Map(
-                [...appTxMap.entries()].sort(
-                    (keyValueArrayA, keyValueArrayB) =>
-                        keyValueArrayB[1] - keyValueArrayA[1],
-                ),
-            );
-            tgMsg.push(
-                `${config.emojis.app} <b><i>${appTxs.toLocaleString(
-                    'en-US',
-                )} app tx${appTxs > 1 ? 's' : ''}</i></b>`,
-            );
-            sortedAppTxMap.forEach((count, lokadId) => {
-                // Do we recognize this app?
-                const supportedLokadApp = lokadMap.get(lokadId);
-                if (typeof supportedLokadApp === 'undefined') {
-                    unknownLokadTxs += count;
-                    // Go to the next lokadId
-                    return;
-                }
-                const { name, emoji, url } = supportedLokadApp;
-                if (typeof url === 'undefined') {
-                    tgMsg.push(
-                        `${emoji} <b>${count.toLocaleString(
-                            'en-US',
-                        )}</b> ${name}${count > 1 ? 's' : ''}`,
-                    );
-                } else {
-                    tgMsg.push(
-                        `${emoji} <b>${count.toLocaleString(
-                            'en-US',
-                        )}</b> <a href="${url}">${name}${
-                            count > 1 ? 's' : ''
-                        }</a>`,
-                    );
-                }
-            });
-            // Add line for unknown txs
-            if (unknownLokadTxs > 0) {
-                tgMsg.push(
-                    `${
-                        config.emojis.unknown
-                    } <b>${unknownLokadTxs.toLocaleString(
-                        'en-US',
-                    )}</b> Unknown app tx${unknownLokadTxs > 1 ? 's' : ''}`,
-                );
-            }
-            tgMsg.push('');
-        }
-
-        if (binanceWithdrawalCount > 0) {
-            // Binance hot wallet
-            const binanceWithdrawalXec =
-                binanceWithdrawalSats / SATOSHIS_PER_XEC;
-            const renderedBinanceWithdrawalSats =
-                typeof xecPriceUsd !== 'undefined'
-                    ? `$${(binanceWithdrawalXec * xecPriceUsd).toLocaleString(
-                          'en-US',
-                          {
-                              minimumFractionDigits: 0,
-                              maximumFractionDigits: 0,
-                          },
-                      )}`
-                    : `${binanceWithdrawalXec.toLocaleString('en-US', {
-                          minimumFractionDigits: 0,
-                          maximumFractionDigits: 0,
-                      })} XEC`;
-            tgMsg.push(`${config.emojis.bank} <b><i>Binance</i></b>`);
-            tgMsg.push(
-                `<b>${binanceWithdrawalCount}</b> withdrawal${
-                    binanceWithdrawalCount > 1 ? 's' : ''
-                }, ${renderedBinanceWithdrawalSats}`,
-            );
-        }
-
-        return splitOverflowTgMsg(tgMsg);
-    },
-    /**
-     * Initialize action data for a token if not yet intialized
-     * Update action count if initialized
-     * @param {map} tokenActionMap
-     * @param {object | undefined} existingAction result from tokenActionMap.get(tokenId)
-     * @param {string} tokenId
-     * @param {string} action
-     */
-    initializeOrIncrementTokenData: function (
-        tokenActionMap,
-        existingActions,
-        tokenId,
-        action,
-    ) {
-        tokenActionMap.set(
-            tokenId,
-            typeof existingActions === 'undefined'
-                ? {
-                      [action]: {
-                          count: 1,
-                      },
-                      actionCount: 1,
-                  }
-                : {
-                      ...existingActions,
-                      [action]: {
-                          count:
-                              action in existingActions
-                                  ? existingActions[action].count + 1
-                                  : 1,
-                      },
-                      actionCount: existingActions.actionCount + 1,
-                  },
-        );
-    },
-};
diff --git a/apps/ecash-herald/src/parse.ts b/apps/ecash-herald/src/parse.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/src/parse.ts
@@ -0,0 +1,3701 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import config from '../config';
+import opReturn from '../constants/op_return';
+import { consume, consumeNextPush, swapEndianness } from 'ecash-script';
+import knownMinersJson, { KnownMiners, MinerInfo } from '../constants/miners';
+import cachedTokenInfoMap from '../constants/tokens';
+import {
+    jsonReviver,
+    bigNumberAmountToLocaleString,
+    CoinGeckoPrice,
+} from '../src/utils';
+import cashaddr from 'ecashaddrjs';
+import BigNumber from 'bignumber.js';
+import {
+    TOKEN_SERVER_OUTPUTSCRIPT,
+    BINANCE_OUTPUTSCRIPT,
+} from '../constants/senders';
+import { prepareStringForTelegramHTML, splitOverflowTgMsg } from './telegram';
+import { OutputscriptInfo } from './chronik';
+import {
+    formatPrice,
+    satsToFormattedValue,
+    returnAddressPreview,
+    containsOnlyPrintableAscii,
+} from './utils';
+import lokadMap from '../constants/lokad';
+import { scriptOps } from 'ecash-agora';
+import { Script, fromHex, OP_0 } from 'ecash-lib';
+import {
+    ChronikClient,
+    CoinbaseData,
+    Tx,
+    TxOutput,
+    GenesisInfo,
+} from 'chronik-client';
+import { MemoryCache } from 'cache-manager';
+
+const miners: KnownMiners = JSON.parse(
+    JSON.stringify(knownMinersJson),
+    jsonReviver,
+);
+
+// Constants for SLP 1 token types as returned by chronik-client
+const SLP_1_PROTOCOL_NUMBER = 1;
+const SLP_1_NFT_COLLECTION_PROTOCOL_NUMBER = 129;
+const SLP_1_NFT_PROTOCOL_NUMBER = 65;
+
+// Miner fund output script
+const minerFundOutputScript = 'a914d37c4c809fe9840e7bfa77b86bd47163f6fb6c6087';
+
+interface PriceInfo {
+    usd: number;
+    usd_market_cap: number;
+    usd_24h_vol: number;
+    usd_24h_change: number;
+}
+interface HeraldStaker {
+    staker: string;
+    reward: number;
+}
+interface HeraldOpReturnInfo {
+    app: string;
+    msg: string;
+    stackArray?: string[];
+    tokenId?: string | false;
+}
+interface TokenSendInfo {
+    tokenId: string;
+    parsedTokenType: string;
+    txType: string;
+    tokenChangeOutputs?: Map<string, BigNumber>;
+    tokenReceivingOutputs?: Map<string, BigNumber>;
+    tokenSendingOutputScripts?: Set<string>;
+}
+interface HeraldParsedTx {
+    txid: string;
+    genesisInfo: false | { tokenId: string };
+    opReturnInfo: false | HeraldOpReturnInfo;
+    txFee: number;
+    xecSendingOutputScripts: Set<string>;
+    xecReceivingOutputs: Map<string, number>;
+    totalSatsSent: number;
+    tokenSendInfo: false | TokenSendInfo;
+    tokenBurnInfo:
+        | false
+        | {
+              tokenId: string;
+              undecimalizedTokenBurnAmount: string;
+          };
+}
+export interface HeraldParsedBlock {
+    hash: string;
+    height: number;
+    miner: string;
+    staker: HeraldStaker | false;
+    numTxs: number;
+    parsedTxs: HeraldParsedTx[];
+    tokenIds: Set<string>;
+    outputScripts: Set<string>;
+}
+
+enum TrackedTokenAction {
+    Genesis = 'genesis',
+    Send = 'send',
+    Mint = 'mint',
+    Burn = 'burn',
+    Buy = 'buy',
+    List = 'list',
+    AdPrep = 'adPrep',
+    Cancel = 'cancel',
+}
+interface TokenAction {
+    count: number;
+}
+interface TokenActions {
+    actionCount: number;
+    send?: TokenAction;
+    mint?: TokenAction;
+    burn?: TokenAction;
+    adPrep?: TokenAction;
+    buy?: TokenAction;
+    list?: TokenAction;
+    cancel?: TokenAction;
+    genesis?:
+        | TokenAction
+        | { hasBaton: boolean; amount: string; count?: number };
+}
+/**
+ * Parse a finalized block for newsworthy information
+ * @param blockHash
+ * @param blockHeight
+ * @param txs
+ */
+export const parseBlockTxs = (
+    blockHash: string,
+    blockHeight: number,
+    txs: Tx[],
+): HeraldParsedBlock => {
+    // Parse coinbase string
+    const coinbaseTx = txs[0];
+    const miner = getMinerFromCoinbaseTx(
+        coinbaseTx.inputs[0].inputScript,
+        coinbaseTx.outputs,
+        miners,
+    );
+    let staker = getStakerFromCoinbaseTx(blockHeight, coinbaseTx.outputs);
+    if (staker !== false) {
+        try {
+            staker.staker = cashaddr.encodeOutputScript(staker.staker);
+        } catch (err) {
+            staker.staker = 'script(' + staker.staker + ')';
+        }
+    }
+
+    // Start with i=1 to skip Coinbase tx
+    let parsedTxs = [];
+    for (let i = 1; i < txs.length; i += 1) {
+        parsedTxs.push(parseTx(txs[i]));
+    }
+
+    // Sort parsedTxs by totalSatsSent, highest to lowest
+    parsedTxs = parsedTxs.sort((a, b) => {
+        return b.totalSatsSent - a.totalSatsSent;
+    });
+
+    // Collect token info needed to parse token send txs
+    const tokenIds: Set<string> = new Set(); // we only need each tokenId once
+    // Collect outputScripts seen in this block to parse for balance
+    let outputScripts: Set<string> = new Set();
+    for (let i = 0; i < parsedTxs.length; i += 1) {
+        const thisParsedTx = parsedTxs[i];
+        if (thisParsedTx.tokenSendInfo) {
+            tokenIds.add(thisParsedTx.tokenSendInfo.tokenId);
+        }
+        if (thisParsedTx.genesisInfo) {
+            tokenIds.add(thisParsedTx.genesisInfo.tokenId);
+        }
+        if (thisParsedTx.tokenBurnInfo) {
+            tokenIds.add(thisParsedTx.tokenBurnInfo.tokenId);
+        }
+        // Some OP_RETURN txs also have token IDs we need to parse
+        // SWaP txs, (TODO: airdrop txs)
+        if (thisParsedTx.opReturnInfo && thisParsedTx.opReturnInfo.tokenId) {
+            tokenIds.add(thisParsedTx.opReturnInfo.tokenId);
+        }
+        const { xecSendingOutputScripts, xecReceivingOutputs } = thisParsedTx;
+
+        // Only add the first sending and receiving output script,
+        // As you will only render balance emojis for these
+        outputScripts.add(xecSendingOutputScripts.values().next().value!);
+
+        // For receiving outputScripts, add the first that is not OP_RETURN
+        // So, get an array of the outputScripts first
+        const xecReceivingOutputScriptsArray: string[] = Array.from(
+            xecReceivingOutputs.keys(),
+        );
+        for (let j = 0; j < xecReceivingOutputScriptsArray.length; j += 1) {
+            if (
+                !xecReceivingOutputScriptsArray[j].startsWith(
+                    opReturn.opReturnPrefix,
+                )
+            ) {
+                outputScripts.add(xecReceivingOutputScriptsArray[j]);
+                // Exit loop after you've added the first non-OP_RETURN outputScript
+                break;
+            }
+        }
+    }
+    return {
+        hash: blockHash,
+        height: blockHeight,
+        miner,
+        staker,
+        numTxs: txs.length,
+        parsedTxs,
+        tokenIds,
+        outputScripts,
+    };
+};
+export const getStakerFromCoinbaseTx = (
+    blockHeight: number,
+    coinbaseOutputs: TxOutput[],
+): HeraldStaker | false => {
+    const STAKING_ACTIVATION_HEIGHT = 818670;
+    if (blockHeight < STAKING_ACTIVATION_HEIGHT) {
+        // Do not parse for staking rwds if they are not expected to exist
+        return false;
+    }
+    const STAKING_REWARDS_PERCENT = 10;
+    const totalCoinbaseSats = coinbaseOutputs
+        .map(output => output.value)
+        .reduce((prev, curr) => prev + curr, 0);
+    for (let output of coinbaseOutputs) {
+        const thisValue = output.value;
+        const minStakerValue = Math.floor(
+            totalCoinbaseSats * STAKING_REWARDS_PERCENT * 0.01,
+        );
+        // In practice, the staking reward will almost always be the one that is exactly 10% of totalCoinbaseSats
+        // Use a STAKER_PERCENT_PADDING range to exclude miner and ifp outputs
+        const STAKER_PERCENT_PADDING = 1;
+        const assumedMaxStakerValue = Math.floor(
+            totalCoinbaseSats *
+                (STAKING_REWARDS_PERCENT + STAKER_PERCENT_PADDING) *
+                0.01,
+        );
+        if (thisValue >= minStakerValue && thisValue <= assumedMaxStakerValue) {
+            return {
+                // Return the script, there is no guarantee that we can use
+                // an address to display this.
+                staker: output.outputScript,
+                reward: thisValue,
+            };
+        }
+    }
+    // If you don't find a staker, don't add it in msg. Can troubleshoot if see this in the app.
+    // This can happen if a miner overpays rwds, underpays miner rwds
+    return false;
+};
+
+export const getMinerFromCoinbaseTx = (
+    coinbaseScriptsig: string,
+    coinbaseOutputs: TxOutput[],
+    knownMiners: KnownMiners,
+): string => {
+    // When you find the miner, minerInfo will come from knownMiners
+    let minerInfo: boolean | MinerInfo = false;
+
+    // First, check outputScripts for a known miner
+    for (let i = 0; i < coinbaseOutputs.length; i += 1) {
+        const thisOutputScript = coinbaseOutputs[i].outputScript;
+        const knownMinerInfo = knownMiners.get(thisOutputScript);
+        if (typeof knownMinerInfo !== 'undefined') {
+            minerInfo = knownMinerInfo;
+            break;
+        }
+    }
+
+    if (!minerInfo) {
+        // If you still haven't found minerInfo, test by known pattern of coinbase script
+        // Possibly a known miner is using a new address
+        knownMiners.forEach(knownMinerInfo => {
+            const { coinbaseHexFragment } = knownMinerInfo;
+            if (coinbaseScriptsig.includes(coinbaseHexFragment)) {
+                minerInfo = knownMinerInfo;
+            }
+        });
+    }
+
+    if (!minerInfo) {
+        // We're still unable to identify the miner, so resort to
+        // indentifying by the last chars of the payout address. For now
+        // we assume the ordering of outputs such as the miner reward is at
+        // the first position.
+        const minerPayoutSript = coinbaseOutputs[0].outputScript;
+        try {
+            const minerAddress = cashaddr.encodeOutputScript(minerPayoutSript);
+            return `unknown, ...${minerAddress.slice(-4)}`;
+        } catch (err) {
+            console.log(
+                `Error converting miner payout script (${minerPayoutSript}) to eCash address`,
+                err,
+            );
+            // Give up
+            return 'unknown';
+        }
+    }
+
+    // If you have found the miner, parse coinbase hex for additional info
+    switch (minerInfo.miner) {
+        // This is available for ViaBTC and CK Pool
+        // Use a switch statement to easily support adding future miners
+        case 'ViaBTC':
+        // Intentional fall-through so ViaBTC and CKPool have same parsing
+        // es-lint ignore no-fallthrough
+        case 'CK Pool': {
+            /* For ViaBTC, the interesting info is between '/' characters
+             * i.e. /Mined by 260786/
+             * In ascii, these are encoded with '2f'
+             */
+            const infoHexParts = coinbaseScriptsig.split('2f');
+
+            // Because the characters before and after the info we are looking for could also
+            // contain '2f', we need to find the right part
+
+            // The right part is the one that comes immediately after coinbaseHexFragment
+            let infoAscii = '';
+            for (let i = 0; i < infoHexParts.length; i += 1) {
+                if (infoHexParts[i].includes(minerInfo.coinbaseHexFragment)) {
+                    // We want the next one, if it exists
+                    if (i + 1 < infoHexParts.length) {
+                        infoAscii = Buffer.from(
+                            infoHexParts[i + 1],
+                            'hex',
+                        ).toString('ascii');
+                    }
+                    break;
+                }
+            }
+
+            if (infoAscii === 'mined by IceBerg') {
+                // CK Pool, mined by IceBerg
+                // If this is IceBerg, identify uniquely
+                // Iceberg is probably a solo miner using CK Pool software
+                return `IceBerg`;
+            }
+
+            if (infoAscii === 'mined by iceberg') {
+                // If the miner self identifies as iceberg, go with it
+                return `iceberg`;
+            }
+
+            // Return your improved 'miner' info
+            // ViaBTC, Mined by 260786
+            if (infoAscii.length === 0) {
+                // If you did not find anything interesting, just return the miner
+                return minerInfo.miner;
+            }
+            return `${minerInfo.miner}, ${infoAscii}`;
+        }
+        default: {
+            // Unless the miner has specific parsing rules defined above, no additional info is available
+            return minerInfo.miner;
+        }
+    }
+};
+
+/**
+ * Parse an eCash tx as returned by chronik for newsworthy information
+ */
+export const parseTx = (tx: Tx): HeraldParsedTx => {
+    const { txid, inputs, outputs } = tx;
+
+    let isTokenTx = false;
+    let genesisInfo: false | { tokenId: string } = false;
+    let opReturnInfo: false | HeraldOpReturnInfo = false;
+
+    /* Token send parsing info
+     *
+     * Note that token send amounts received from chronik do not account for
+     * token decimals. Decimalized amounts require token genesisInfo
+     * decimals param to calculate
+     */
+
+    /* tokenSendInfo
+     * `false` for txs that are not etoken send txs
+     * an object containing info about the token send for token send txs
+     */
+    let tokenSendInfo: false | TokenSendInfo = false;
+    let tokenSendingOutputScripts: Set<string> = new Set();
+    let tokenReceivingOutputs = new Map();
+    let tokenChangeOutputs = new Map();
+    let undecimalizedTokenInputAmount = new BigNumber(0);
+
+    // tokenBurn parsing variables
+    let tokenBurnInfo:
+        | false
+        | {
+              tokenId: string;
+              undecimalizedTokenBurnAmount: string;
+          } = false;
+
+    /* Collect xecSendInfo for all txs, since all txs are XEC sends
+     * You may later want to render xecSendInfo for tokenSends, appTxs, etc,
+     * maybe on special conditions, e.g.a token send tx that also sends a bunch of xec
+     */
+
+    // xecSend parsing variables
+    let xecSendingOutputScripts: Set<string> = new Set();
+    let xecReceivingOutputs = new Map();
+    let xecInputAmountSats = 0;
+    let xecOutputAmountSats = 0;
+    let totalSatsSent = 0;
+    let changeAmountSats = 0;
+
+    if (
+        tx.tokenStatus !== 'TOKEN_STATUS_NON_TOKEN' &&
+        tx.tokenEntries.length > 0
+    ) {
+        isTokenTx = true;
+
+        // We may have more than one token action in a given tx
+        // chronik will reflect this by having multiple entries in the tokenEntries array
+
+        // For now, just parse the first action
+        // TODO handle txs with multiple tokenEntries
+        const parsedTokenAction = tx.tokenEntries[0];
+
+        const { tokenId, tokenType, txType, burnSummary, actualBurnAmount } =
+            parsedTokenAction;
+        const { protocol, number } = tokenType;
+        const isUnintentionalBurn =
+            burnSummary !== '' && actualBurnAmount !== '0';
+
+        // Get token type
+        // TODO present the token type in msgs
+        let parsedTokenType = '';
+        switch (protocol) {
+            case 'ALP': {
+                parsedTokenType = 'ALP';
+                break;
+            }
+            case 'SLP': {
+                if (number === SLP_1_PROTOCOL_NUMBER) {
+                    parsedTokenType = 'SLP';
+                } else if (number === SLP_1_NFT_COLLECTION_PROTOCOL_NUMBER) {
+                    parsedTokenType = 'NFT Collection';
+                } else if (number === SLP_1_NFT_PROTOCOL_NUMBER) {
+                    parsedTokenType = 'NFT';
+                }
+                break;
+            }
+            default: {
+                parsedTokenType = `${protocol} ${number}`;
+                break;
+            }
+        }
+
+        switch (txType) {
+            case 'GENESIS': {
+                // Note that NNG chronik provided genesisInfo in this tx
+                // Now we get it from chronik.token
+                // Initialize genesisInfo object with tokenId so it can be rendered into a msg later
+                genesisInfo = { tokenId };
+                break;
+            }
+            case 'SEND': {
+                if (isUnintentionalBurn) {
+                    tokenBurnInfo = {
+                        tokenId,
+                        undecimalizedTokenBurnAmount: actualBurnAmount,
+                    };
+                } else {
+                    tokenSendInfo = {
+                        tokenId,
+                        parsedTokenType,
+                        txType,
+                    };
+                }
+                break;
+            }
+            // TODO handle MINT
+            default: {
+                // For now, if we can't parse as above, this will be parsed as an eCash tx (or EMPP)
+                break;
+            }
+        }
+    }
+    for (const input of inputs) {
+        if (typeof input.outputScript !== 'undefined') {
+            xecSendingOutputScripts.add(input.outputScript);
+        }
+
+        xecInputAmountSats += input.value;
+        // The input that sent the token utxos will have key 'slpToken'
+        if (typeof input.token !== 'undefined') {
+            // Add amount to undecimalizedTokenInputAmount
+            // TODO make sure this is for the correct tokenID
+            // Could have mistakes in parsing ALP txs otherwise
+            // For now, this is outside the scope of migration
+            undecimalizedTokenInputAmount = undecimalizedTokenInputAmount.plus(
+                input.token.amount,
+            );
+            // Collect the input outputScripts to identify change output
+            if (typeof input.outputScript !== 'undefined') {
+                tokenSendingOutputScripts.add(input.outputScript);
+            }
+        }
+    }
+
+    // Iterate over outputs to check for OP_RETURN msgs
+    for (const output of outputs) {
+        const { value, outputScript } = output;
+        xecOutputAmountSats += value;
+        // If this output script is the same as one of the sendingOutputScripts
+        if (xecSendingOutputScripts.has(outputScript)) {
+            // Then this XEC amount is change
+            changeAmountSats += value;
+        } else {
+            // Add an xecReceivingOutput
+
+            // Add outputScript and value to map
+            // If this outputScript is already in xecReceivingOutputs, increment its value
+            xecReceivingOutputs.set(
+                outputScript,
+                (xecReceivingOutputs.get(outputScript) ?? 0) + value,
+            );
+
+            // Increment totalSatsSent
+            totalSatsSent += value;
+        }
+        // Don't parse OP_RETURN values of etoken txs, this info is available from chronik
+        if (outputScript.startsWith(opReturn.opReturnPrefix) && !isTokenTx) {
+            opReturnInfo = parseOpReturn(outputScript.slice(2));
+        }
+        // For etoken send txs, parse outputs for tokenSendInfo object
+        if (typeof output.token !== 'undefined') {
+            // TODO handle EMPP and potential token txs with multiple tokens involved
+            // Check output script to confirm does not match tokenSendingOutputScript
+            if (tokenSendingOutputScripts.has(outputScript)) {
+                // change
+                tokenChangeOutputs.set(
+                    outputScript,
+                    (
+                        tokenChangeOutputs.get(outputScript) ?? new BigNumber(0)
+                    ).plus(output.token.amount),
+                );
+            } else {
+                /* This is the sent token qty
+                 *
+                 * Add outputScript and undecimalizedTokenReceivedAmount to map
+                 * If this outputScript is already in tokenReceivingOutputs, increment undecimalizedTokenReceivedAmount
+                 * note that thisOutput.slpToken.amount is a string so you do not want to add it
+                 * BigNumber library is required for token calculations
+                 */
+                tokenReceivingOutputs.set(
+                    outputScript,
+                    (
+                        tokenReceivingOutputs.get(outputScript) ??
+                        new BigNumber(0)
+                    ).plus(output.token.amount),
+                );
+            }
+        }
+    }
+
+    // Determine tx fee
+    const txFee = xecInputAmountSats - xecOutputAmountSats;
+
+    // If this is a token send tx, return token send parsing info and not 'false' for tokenSendInfo
+    if (tokenSendInfo) {
+        tokenSendInfo.tokenChangeOutputs = tokenChangeOutputs;
+        tokenSendInfo.tokenReceivingOutputs = tokenReceivingOutputs;
+        tokenSendInfo.tokenSendingOutputScripts = tokenSendingOutputScripts;
+    }
+
+    // If this tx sent XEC to itself, reassign changeAmountSats to totalSatsSent
+    // Need to do this to prevent self-send txs being sorted at the bottom of msgs
+    if (xecReceivingOutputs.size === 0) {
+        totalSatsSent = changeAmountSats;
+    }
+
+    return {
+        txid,
+        genesisInfo,
+        opReturnInfo,
+        txFee,
+        xecSendingOutputScripts,
+        xecReceivingOutputs,
+        totalSatsSent,
+        tokenSendInfo,
+        tokenBurnInfo,
+    };
+};
+
+/**
+ *
+ * @param {string} opReturnHex an OP_RETURN outputScript with '6a' removed
+ * @returns {object} {app, msg} an object with app and msg params used to generate msg
+ */
+export const parseOpReturn = (opReturnHex: string): HeraldOpReturnInfo => {
+    // Initialize required vars
+    let app;
+    let msg;
+    let tokenId: string | false = false;
+
+    // Get array of pushes
+    let stack = { remainingHex: opReturnHex };
+    let stackArray = [];
+    while (stack.remainingHex.length > 0) {
+        const { data } = consumeNextPush(stack);
+        if (data !== '') {
+            // You may have an empty push in the middle of a complicated tx for some reason
+            // Mb some libraries erroneously create these
+            // e.g. https://explorer.e.cash/tx/70c2842e1b2c7eb49ee69cdecf2d6f3cd783c307c4cbeef80f176159c5891484
+            // has 4c000100 for last characters. 4c00 is just nothing.
+            // But you want to know 00 and have the correct array index
+            stackArray.push(data);
+        }
+    }
+
+    // Get the protocolIdentifier, the first push
+    const protocolIdentifier = stackArray[0];
+
+    // Test for memo
+    // Memo prefixes are special in that they are two bytes instead of the usual four
+    // Also, memo has many prefixes, in that the action is also encoded in these two bytes
+    if (
+        protocolIdentifier.startsWith(opReturn.memo.prefix) &&
+        protocolIdentifier.length === 4
+    ) {
+        // If the protocol identifier is two bytes long (4 characters), parse for memo tx
+        // For now, send the same info to this function that it currently parses
+        // TODO parseMemoOutputScript needs to be refactored to use ecash-script
+        return parseMemoOutputScript(stackArray);
+    }
+
+    // Test for other known apps with known msg processing methods
+    switch (protocolIdentifier) {
+        case opReturn.opReserved: {
+            // Parse for empp OP_RETURN
+            // Spec https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/chronik/bitcoinsuite-slp/src/empp/mod.rs
+            return parseMultipushStack(stackArray);
+        }
+        case opReturn.knownApps.alias.prefix: {
+            app = opReturn.knownApps.alias.app;
+            /*
+                For now, parse and render alias txs by going through OP_RETURN
+                When aliases are live, refactor to use alias-server for validation
+                <protocolIdentifier> <version> <alias> <address type + hash>
+
+                Only parse the msg if the tx is constructed correctly
+                */
+            msg =
+                stackArray.length === 4 && stackArray[1] === '00'
+                    ? prepareStringForTelegramHTML(
+                          Buffer.from(stackArray[2], 'hex').toString('utf8'),
+                      )
+                    : 'Invalid alias registration';
+
+            break;
+        }
+        case opReturn.knownApps.airdrop.prefix: {
+            app = opReturn.knownApps.airdrop.app;
+
+            // Initialize msg as empty string. Need tokenId info to complete.
+            msg = '';
+
+            // Airdrop tx has structure
+            // <prefix> <tokenId>
+
+            // Cashtab allows sending a cashtab msg with an airdrop
+            // These look like
+            // <prefix> <tokenId> <cashtabMsgPrefix> <msg>
+            if (stackArray.length >= 2 && stackArray[1].length === 64) {
+                tokenId = stackArray[1];
+            }
+            break;
+        }
+        case opReturn.knownApps.cashtabMsg.prefix: {
+            app = opReturn.knownApps.cashtabMsg.app;
+            // For a Cashtab msg, the next push on the stack is the Cashtab msg
+            // Cashtab msgs use utf8 encoding
+
+            // Valid Cashtab Msg
+            // <protocol identifier> <msg in utf8>
+            msg =
+                stackArray.length >= 2
+                    ? prepareStringForTelegramHTML(
+                          Buffer.from(stackArray[1], 'hex').toString('utf8'),
+                      )
+                    : `Invalid ${app}`;
+            break;
+        }
+        case opReturn.knownApps.cashtabMsgEncrypted.prefix: {
+            app = opReturn.knownApps.cashtabMsgEncrypted.app;
+            // For an encrypted cashtab msg, you can't parse and display the msg
+            msg = '';
+            // You will add info about the tx when you build the msg
+            break;
+        }
+        case opReturn.knownApps.fusionLegacy.prefix:
+        case opReturn.knownApps.fusion.prefix: {
+            /**
+             * Cash Fusion tx
+             * <protocolIdentifier> <sessionHash>
+             * https://github.com/cashshuffle/spec/blob/master/CASHFUSION.md
+             */
+            app = opReturn.knownApps.fusion.app;
+            // The session hash is not particularly interesting to users
+            // Provide tx info in telegram prep function
+            msg = '';
+            break;
+        }
+        case opReturn.knownApps.swap.prefix: {
+            // Swap txs require special parsing that should be done in getSwapTgMsg
+            // We may need to get info about a token ID before we can
+            // create a good msg
+            app = opReturn.knownApps.swap.app;
+            msg = '';
+
+            if (
+                stackArray.length >= 3 &&
+                stackArray[1] === '01' &&
+                stackArray[2] === '01' &&
+                stackArray[3].length === 64
+            ) {
+                // If this is a signal for buy or sell of a token, save the token id
+                // Ref https://github.com/vinarmani/swap-protocol/blob/master/swap-protocol-spec.md
+                // A buy or sell signal tx will have '01' at stackArray[1] and stackArray[2] and
+                // token id at stackArray[3]
+                tokenId = stackArray[3];
+            }
+            break;
+        }
+        case opReturn.knownApps.payButton.prefix: {
+            app = opReturn.knownApps.payButton.app;
+            // PayButton v0
+            // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/doc/standards/paybutton.md
+            // <lokad> <OP_0> <data> <nonce>
+            // The data could be interesting, ignore the rest
+            if (stackArray.length >= 3) {
+                // Version byte is at index 1
+                const payButtonTxVersion = stackArray[1];
+                if (payButtonTxVersion !== '00') {
+                    msg = `Unsupported version: 0x${payButtonTxVersion}`;
+                } else {
+                    const dataPush = stackArray[2];
+                    if (dataPush === '00') {
+                        // Per spec, PayButton txs with no data push OP_0 in this position
+                        msg = 'no data';
+                    } else {
+                        // Data is utf8 encoded
+                        msg = prepareStringForTelegramHTML(
+                            Buffer.from(stackArray[2], 'hex').toString('utf8'),
+                        );
+                    }
+                }
+            } else {
+                msg = '[off spec]';
+            }
+            break;
+        }
+        case opReturn.knownApps.paywall.prefix: {
+            app = opReturn.knownApps.paywall.app;
+            // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/doc/standards/op_return-prefix-guideline.md
+            // <lokad> <txid of the article this paywall is paying for>
+            if (stackArray.length === 2) {
+                const articleTxid = stackArray[1];
+                if (
+                    typeof articleTxid === 'undefined' ||
+                    articleTxid.length !== 64
+                ) {
+                    msg = `Invalid paywall article txid`;
+                } else {
+                    msg = `<a href="${config.blockExplorer}/tx/${articleTxid}">Article paywall payment</a>`;
+                }
+            } else {
+                msg = '[off spec paywall payment]';
+            }
+            break;
+        }
+        case opReturn.knownApps.authentication.prefix: {
+            app = opReturn.knownApps.authentication.app;
+            // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/doc/standards/op_return-prefix-guideline.md
+            // <lokad> <authentication identifier>
+            if (stackArray.length === 2) {
+                const authenticationHex = stackArray[1];
+                if (authenticationHex === '00') {
+                    msg = `Invalid eCashChat authentication identifier`;
+                } else {
+                    msg = 'eCashChat authentication via dust tx';
+                }
+            } else {
+                msg = '[off spec eCashChat authentication]';
+            }
+            break;
+        }
+        default: {
+            // If you do not recognize the protocol identifier, just print the pushes in hex
+            // If it is an app or follows a pattern, can be added later
+            app = 'unknown';
+
+            if (containsOnlyPrintableAscii(stackArray.join(''))) {
+                msg = prepareStringForTelegramHTML(
+                    Buffer.from(stackArray.join(''), 'hex').toString('ascii'),
+                );
+            } else {
+                // If you have non-ascii characters, print each push as a hex number
+                msg = '';
+                for (let i = 0; i < stackArray.length; i += 1) {
+                    msg += `0x${stackArray[i]} `;
+                }
+                // Remove the last space
+                msg = msg.slice(0, -1);
+
+                // Trim the msg for Telegram to avoid 200+ char msgs
+                const unknownMaxChars = 20;
+                if (msg.length > unknownMaxChars) {
+                    msg = msg.slice(0, unknownMaxChars) + '...';
+                }
+            }
+
+            break;
+        }
+    }
+
+    return { app, msg, stackArray, tokenId };
+};
+
+/**
+ * Parse an empp stack for a simplified slp v2 description
+ * TODO expand for parsing other types of empp txs as specs or examples are known
+ * @param {array} emppStackArray an array containing a hex string for every push of this memo OP_RETURN outputScript
+ * @returns {object} {app, msg} used to compose a useful telegram msg describing the transaction
+ */
+export const parseMultipushStack = (
+    emppStackArray: string[],
+): HeraldOpReturnInfo => {
+    // Note that an empp push may not necessarily include traditionally parsed pushes
+    // i.e. consumeNextPush({remainingHex:<emppPush>}) may throw an error
+    // For example, SLPv2 txs do not include a push for their prefix
+
+    // So, parsing empp txs will require specific rules depending on the type of tx
+    let msgs = [];
+
+    // Start at i=1 because emppStackArray[0] is OP_RESERVED
+    for (let i = 1; i < emppStackArray.length; i += 1) {
+        if (emppStackArray[i].slice(0, 8) === opReturn.knownApps.slp2.prefix) {
+            // Parse string for slp v2
+            const thisMsg = parseSlpTwo(emppStackArray[i].slice(8));
+            msgs.push(`${opReturn.knownApps.slp2.app}:${thisMsg}`);
+        } else {
+            // Since we don't know any spec or parsing rules for other types of EMPP pushes,
+            // Just add an ASCII decode of the whole thing if you see one
+            msgs.push(
+                `${'Unknown App:'}${Buffer.from(
+                    emppStackArray[i],
+                    'hex',
+                ).toString('ascii')}`,
+            );
+        }
+        // Do not parse any other empp (haven't seen any in the wild, no existing specs to follow)
+    }
+    return { app: 'EMPP', msg: msgs.length > 0 ? msgs.join('|') : '' };
+};
+
+/**
+ * Stub method to parse slp two empps
+ * @param {string} slpTwoPush a string of hex characters in an empp tx representing an slp2 push
+ * @returns {string} For now, just the section type, if token type is correct
+ */
+export const parseSlpTwo = (slpTwoPush: string): string => {
+    // Parse an empp push hex string with the SLP protocol identifier removed per SLP v2 spec
+    // https://ecashbuilders.notion.site/SLPv2-a862a4130877448387373b9e6a93dd97
+
+    let msg = '';
+
+    // Create a stack to use ecash-script consume function
+    // Note: slp2 parsing is not standard op_return parsing, varchar bytes just use a one-byte push
+    // So, you can use the 'consume' function of ecash-script, but not consumeNextPush
+    let stack = { remainingHex: slpTwoPush };
+
+    // 1.3: Read token type
+    // For now, this can only be 00. If not 00, unknown
+    const tokenType = consume(stack, 1);
+
+    if (tokenType !== '00') {
+        msg += 'Unknown token type|';
+    }
+
+    // 1.4: Read section type
+    // These are custom varchar per slp2 spec
+    // <varchar byte hex> <section type>
+    const sectionBytes = parseInt(consume(stack, 1), 16);
+    // Note: these are encoded with push data, so you can use ecash-script
+
+    const sectionType = Buffer.from(
+        consume(stack, sectionBytes),
+        'hex',
+    ).toString('utf8');
+    msg += sectionType;
+
+    // Parsing differs depending on section type
+    // Note that SEND and MINT have same parsing
+
+    const TOKEN_ID_BYTES = 32;
+    switch (sectionType) {
+        case 'SEND':
+        case 'MINT': {
+            // Next up is tokenId
+            const tokenId = swapEndianness(consume(stack, TOKEN_ID_BYTES));
+
+            const cachedTokenInfo = cachedTokenInfoMap.get(tokenId);
+
+            msg += `|<a href="${config.blockExplorer}/tx/${tokenId}">${
+                typeof cachedTokenInfo === 'undefined'
+                    ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
+                    : prepareStringForTelegramHTML(cachedTokenInfo.tokenTicker)
+            }</a>`;
+
+            const numOutputs = consume(stack, 1);
+            // Iterate over number of outputs to get total amount sent
+            // Note: this should be handled with an indexer, as we are not parsing for validity here
+            // However, it's still useful information for the herald
+            let totalAmountSent = 0;
+            for (let i = 0; i < numOutputs; i += 1) {
+                totalAmountSent += parseInt(swapEndianness(consume(stack, 6)));
+            }
+            msg +=
+                typeof cachedTokenInfo === 'undefined'
+                    ? ''
+                    : `|${bigNumberAmountToLocaleString(
+                          totalAmountSent.toString(),
+                          cachedTokenInfo.decimals,
+                      )}`;
+            break;
+        }
+
+        case 'GENESIS': {
+            // TODO
+            // Have not seen one of these in the wild yet
+            break;
+        }
+
+        case 'BURN': {
+            // TODO
+            // Have seen some in the wild but not in spec
+            break;
+        }
+    }
+    // The rest of the parsing rules get quite complicated and should be handled in a dedicated library
+    // or indexer
+    return msg;
+};
+
+/**
+ * Parse a stackArray according to OP_RETURN rules to convert to a useful tg msg
+ * @param stackArray an array containing a hex string for every push of this memo OP_RETURN outputScript
+ * @returns A useful string to describe this tx in a telegram msg
+ */
+export const parseMemoOutputScript = (
+    stackArray: string[],
+): HeraldOpReturnInfo => {
+    let app = opReturn.memo.app;
+    let msg = '';
+
+    // Get the action code from stackArray[0]
+    // For memo txs, this will be the last 2 characters of this initial push
+    const actionCode = stackArray[0].slice(-2);
+
+    if (Object.keys(opReturn.memo).includes(actionCode)) {
+        // If you parse for this action code, include its description in the tg msg
+        msg += opReturn.memo[actionCode];
+        // Include a formatting spacer in between action code and newsworthy info
+        msg += '|';
+    }
+
+    switch (actionCode) {
+        case '01': // Set name <name> (1-217 bytes)
+        case '02': // Post memo <message> (1-217 bytes)
+        case '05': // Set profile text <text> (1-217 bytes)
+        case '0d': // Topic Follow <topic_name> (1-214 bytes)
+        case '0e': // Topic Unfollow <topic_name> (1-214 bytes)
+            // Action codes with only 1 push after the protocol identifier
+            // that is utf8 encoded
+
+            // Include decoded utf8 msg
+            // Make sure the OP_RETURN msg does not contain telegram html escape characters
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[1], 'hex').toString('utf8'),
+            );
+            break;
+        case '03':
+            /**
+             * 03 - Reply to memo
+             * <tx_hash> (32 bytes)
+             * <message> (1-184 bytes)
+             */
+
+            // The tx hash is in hex, not utf8 encoded
+            // For now, we don't have much to do with this txid in a telegram bot
+
+            // Link to the liked or reposted memo
+            // Do not remove tg escape characters as you want this to parse
+            msg += `<a href="${config.blockExplorer}/tx/${stackArray[1]}">memo</a>`;
+
+            // Include a formatting spacer
+            msg += '|';
+
+            // Add the reply
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[2], 'hex').toString('utf8'),
+            );
+            break;
+        case '04':
+            /**
+             * 04 - Like / tip memo <tx_hash> (32 bytes)
+             */
+
+            // Link to the liked or reposted memo
+            msg += `<a href="${config.blockExplorer}/tx/${stackArray[1]}">memo</a>`;
+            break;
+        case '0b': {
+            // 0b - Repost memo <tx_hash> (32 bytes) <message> (0-184 bytes)
+
+            // Link to the liked or reposted memo
+            msg += `<a href="${config.blockExplorer}/tx/${stackArray[1]}">memo</a>`;
+
+            // Include a formatting spacer
+            msg += '|';
+
+            // Add the msg
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[2], 'hex').toString('utf8'),
+            );
+
+            break;
+        }
+        case '06':
+        case '07':
+        case '16':
+        case '17': {
+            /**
+             * Follow user - 06 <address> (20 bytes)
+             * Unfollow user - 07 <address> (20 bytes)
+             * Mute user - 16 <address> (20 bytes)
+             * Unmute user - 17 <address> (20 bytes)
+             */
+
+            // The address is a hex-encoded hash160
+            // all memo addresses are p2pkh
+            const address = cashaddr.encode('ecash', 'P2PKH', stackArray[1]);
+
+            // Link to the address in the msg
+            msg += `<a href="${
+                config.blockExplorer
+            }/address/${address}">${returnAddressPreview(address)}</a>`;
+            break;
+        }
+        case '0a': {
+            // 01 - Set profile picture
+            // <url> (1-217 bytes)
+
+            // url is utf8 encoded stack[1]
+            const url = Buffer.from(stackArray[1], 'hex').toString('utf8');
+            // Link to it
+            msg += `<a href="${url}">[img]</a>`;
+            break;
+        }
+        case '0c': {
+            /**
+             * 0c - Post Topic Message
+             * <topic_name> (1-214 bytes)
+             * <message> (1-[214-len(topic_name)] bytes)
+             */
+
+            // Add the topic
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[1], 'hex').toString('utf8'),
+            );
+
+            // Add a format spacer
+            msg += '|';
+
+            // Add the topic msg
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[2], 'hex').toString('utf8'),
+            );
+            break;
+        }
+        case '10': {
+            /**
+             * 10 - Create Poll
+             * <poll_type> (1 byte)
+             * <option_count> (1 byte)
+             * <question> (1-209 bytes)
+             * */
+
+            // You only need the question here
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[3], 'hex').toString('utf8'),
+            );
+
+            break;
+        }
+        case '13': {
+            /**
+             * 13 Add poll option
+             * <poll_tx_hash> (32 bytes)
+             * <option> (1-184 bytes)
+             */
+
+            // Only parse the option for now
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[2], 'hex').toString('utf8'),
+            );
+
+            break;
+        }
+        case '14': {
+            /**
+             * 14 - Poll Vote
+             * <poll_tx_hash> (32 bytes)
+             * <comment> (0-184 bytes)
+             */
+
+            // We just want the comment
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[2], 'hex').toString('utf8'),
+            );
+
+            break;
+        }
+        case '20':
+        case '24':
+        case '26': {
+            /**
+             * 20 - Link request
+             * 24 - Send money
+             * 26 - Set address alias
+             * <address_hash> (20 bytes)
+             * <message> (1-194 bytes)
+             */
+
+            // The address is a hex-encoded hash160
+            // all memo addresses are p2pkh
+            const address = cashaddr.encode('ecash', 'P2PKH', stackArray[1]);
+
+            // Link to the address in the msg
+            msg += `<a href="${
+                config.blockExplorer
+            }/address/${address}">${returnAddressPreview(address)}</a>`;
+
+            // Add a format spacer
+            msg += '|';
+
+            // Add the msg
+            msg += prepareStringForTelegramHTML(
+                Buffer.from(stackArray[2], 'hex').toString('utf8'),
+            );
+            break;
+        }
+        case '21':
+        case '22':
+        case '30':
+        case '31':
+        case '32':
+        case '35': {
+            /**
+             * https://github.com/memocash/mips/blob/master/mip-0009/mip-0009.md#specification
+             *
+             * These would require additional processing to get info about the specific tokens
+             * For now, not worth it. Just print the action.
+             *
+             * 21 - Link accept
+             * 22 - Link revoke
+             * 30 - Sell tokens
+             * 31 - Token buy offer
+             * 32 - Attach token sale signature
+             * 35 - Pin token post
+             */
+
+            // Remove formatting spacer
+            msg = msg.slice(0, -1);
+            break;
+        }
+
+        default:
+            msg += `Unknown memo action`;
+    }
+    // Test for msgs that are intended for non-XEC audience
+    if (msg.includes('BCH')) {
+        msg = `[check memo.cash for msg]`;
+    }
+    return { app, msg };
+};
+
+/**
+ * Build a msg about an encrypted cashtab msg tx
+ * @param sendingAddress
+ * @param xecReceivingOutputs
+ * @param coingeckoPrices
+ * @returns msg
+ */
+export const getEncryptedCashtabMsg = (
+    sendingAddress: string,
+    xecReceivingOutputs: Map<string, number>,
+    totalSatsSent: number,
+    coingeckoPrices: false | CoinGeckoPrice[],
+): string => {
+    let displayedSentQtyString = satsToFormattedValue(
+        totalSatsSent,
+        coingeckoPrices,
+    );
+
+    // Remove OP_RETURNs from xecReceivingOutputs
+    let receivingOutputscripts = [];
+    for (const outputScript of xecReceivingOutputs.keys()) {
+        if (!outputScript.startsWith(opReturn.opReturnPrefix)) {
+            receivingOutputscripts.push(outputScript);
+        }
+    }
+
+    let msgRecipientString = `${returnAddressPreview(
+        cashaddr.encodeOutputScript(receivingOutputscripts[0]),
+    )}`;
+    if (receivingOutputscripts.length > 1) {
+        // Subtract 1 because you have already rendered one receiving address
+        msgRecipientString += ` and ${receivingOutputscripts.length - 1} other${
+            receivingOutputscripts.length > 2 ? 's' : ''
+        }`;
+    }
+    return `${returnAddressPreview(
+        sendingAddress,
+    )} sent an encrypted message and ${displayedSentQtyString} to ${msgRecipientString}`;
+};
+
+/**
+ * Parse the stackArray of an airdrop tx to generate a useful telegram msg
+ * @param stackArray
+ * @param airdropSendingAddress
+ * @param airdropRecipientsMap
+ * @param tokenInfo token info for the swapped token. optional. Bool False if API call failed.
+ * @param coingeckoPrices object containing price info from coingecko. Bool False if API call failed.
+ * @returns msg ready to send through Telegram API
+ */
+export const getAirdropTgMsg = (
+    stackArray: string[],
+    airdropSendingAddress: string,
+    airdropRecipientsMap: Map<string, number>,
+    totalSatsAirdropped: number,
+    tokenInfo: false | GenesisInfo,
+    coingeckoPrices: false | CoinGeckoPrice[],
+): string => {
+    // stackArray for an airdrop tx will be
+    // [airdrop_protocol_identifier, airdropped_tokenId, optional_cashtab_msg_protocol_identifier, optional_cashtab_msg]
+
+    // Validate expected format
+    if (stackArray.length < 2 || stackArray[1].length !== 64) {
+        return `Invalid ${opReturn.knownApps.airdrop.app}`;
+    }
+
+    // get tokenId
+    const tokenId = stackArray[1];
+
+    // Intialize msg with preview of sending address
+    let msg = `${returnAddressPreview(airdropSendingAddress)} airdropped `;
+
+    let displayedAirdroppedQtyString = satsToFormattedValue(
+        totalSatsAirdropped,
+        coingeckoPrices,
+    );
+
+    // Add to msg
+    msg += `${displayedAirdroppedQtyString} to ${airdropRecipientsMap.size} holders of `;
+
+    if (tokenInfo) {
+        // If API call to get tokenInfo was successful to tokenInfo !== false
+        const { tokenTicker } = tokenInfo;
+
+        // Link to token id
+        msg += `<a href="${
+            config.blockExplorer
+        }/tx/${tokenId}">${prepareStringForTelegramHTML(tokenTicker)}</a>`;
+    } else {
+        // Note: tokenInfo is false if the API call to chronik fails
+        // Link to token id
+        msg += `<a href="${config.blockExplorer}/tx/${tokenId}">${
+            tokenId.slice(0, 3) + '...' + tokenId.slice(-3)
+        }</a>`;
+    }
+    // Add Cashtab msg if present
+    if (
+        stackArray.length > 3 &&
+        stackArray[2] === opReturn.knownApps.cashtabMsg.prefix
+    ) {
+        msg += '|';
+        msg += prepareStringForTelegramHTML(
+            Buffer.from(stackArray[3], 'hex').toString('utf8'),
+        );
+    }
+    return msg;
+};
+
+/**
+ * Parse the stackArray of a SWaP tx according to spec to generate a useful telegram msg
+ * @param stackArray
+ * @param tokenInfo token info for the swapped token. optional.
+ * @returns msg ready to send through Telegram API
+ */
+export const getSwapTgMsg = (
+    stackArray: string[],
+    tokenInfo: false | GenesisInfo,
+): string => {
+    // Intialize msg
+    let msg = '';
+
+    // Generic validation to handle possible txs with SWaP protocol identifier but unexpected stack
+    if (stackArray.length < 3) {
+        // If stackArray[1] and stackArray[2] do not exist
+        return 'Invalid SWaP';
+    }
+
+    // SWaP txs are complex. Parse stackArray to build msg.
+    // https://github.com/vinarmani/swap-protocol/blob/master/swap-protocol-spec.md
+
+    // First, get swp_msg_class at stackArray[1]
+    // 01 - A Signal
+    // 02 - A payment
+    const swp_msg_class = stackArray[1];
+
+    // Second , get swp_msg_type at stackArray[2]
+    // 01 - SLP Atomic Swap
+    // 02 - Multi-Party Escrow
+    // 03 - Threshold Crowdfunding
+    const swp_msg_type = stackArray[2];
+
+    // Build msg by class and type
+
+    if (swp_msg_class === '01') {
+        msg += 'Signal';
+        msg += '|';
+        switch (swp_msg_type) {
+            case '01': {
+                msg += 'SLP Atomic Swap';
+                msg += '|';
+                /*
+                    <token_id_bytes> <BUY_or_SELL_ascii> <rate_in_sats_int> 
+                    <proof_of_reserve_int> <exact_utxo_vout_hash_bytes> <exact_utxo_index_int> 
+                    <minimum_sats_to_exchange_int>
+
+                    Note that <rate_in_sats_int> is in hex value in the spec example,
+                    but some examples on chain appear to encode this value in ascii
+                    */
+
+                if (tokenInfo) {
+                    const { tokenTicker } = tokenInfo;
+
+                    // Link to token id
+                    msg += `<a href="${config.blockExplorer}/tx/${
+                        stackArray[3]
+                    }">${prepareStringForTelegramHTML(tokenTicker)}</a>`;
+                    msg += '|';
+                } else {
+                    // Note: tokenInfo is false if the API call to chronik fails
+                    // Also false if tokenId is invalid for some reason
+                    // Link to token id if valid
+                    if (stackArray.length >= 3 && stackArray[3].length === 64) {
+                        msg += `<a href="${config.blockExplorer}/tx/${stackArray[3]}">Unknown Token</a>`;
+                        msg += '|';
+                    } else {
+                        msg += 'Invalid tokenId|';
+                    }
+                }
+
+                // buy or sell?
+                msg += Buffer.from(stackArray[4], 'hex').toString('ascii');
+
+                // Add price info if present
+                // price in XEC, must convert <rate_in_sats_int> from sats to XEC
+                if (stackArray.length >= 6) {
+                    // In the wild, have seen some SWaP txs use ASCII for encoding rate_in_sats_int
+                    // Make a determination. Spec does not indicate either way, though spec
+                    // example does use hex.
+                    // If stackArray[5] is more than 4 characters long, assume ascii encoding
+                    let rate_in_sats_int;
+                    if (stackArray[5].length > 4) {
+                        rate_in_sats_int = parseInt(
+                            Buffer.from(stackArray[5], 'hex').toString('ascii'),
+                        );
+                    } else {
+                        rate_in_sats_int = parseInt(stackArray[5], 16);
+                    }
+
+                    msg += ` for ${(rate_in_sats_int / 100).toLocaleString(
+                        'en-US',
+                        {
+                            maximumFractionDigits: 2,
+                        },
+                    )} XEC`;
+                }
+
+                // Display minimum_sats_to_exchange_int
+                // Note: sometimes a SWaP tx will not have this info
+                if (stackArray.length >= 10) {
+                    // In the wild, have seen some SWaP txs use ASCII for encoding minimum_sats_to_exchange_int
+                    // Make a determination. Spec does not indicate either way, though spec
+                    // example does use hex.
+                    // If stackArray[9] is more than 4 characters long, assume ascii encoding
+                    let minimum_sats_to_exchange_int;
+                    if (stackArray[9].length > 4) {
+                        minimum_sats_to_exchange_int = parseInt(
+                            Buffer.from(stackArray[9], 'hex').toString('ascii'),
+                        );
+                    } else {
+                        minimum_sats_to_exchange_int = parseInt(
+                            stackArray[9],
+                            16,
+                        );
+                    }
+                    msg += '|';
+                    msg += `Min trade: ${(
+                        minimum_sats_to_exchange_int / 100
+                    ).toLocaleString('en-US', {
+                        maximumFractionDigits: 2,
+                    })} XEC`;
+                }
+                break;
+            }
+            case '02': {
+                msg += 'Multi-Party Escrow';
+                // TODO additional parsing
+                break;
+            }
+            case '03': {
+                msg += 'Threshold Crowdfunding';
+                // TODO additional parsing
+                break;
+            }
+            default: {
+                // Malformed SWaP tx
+                msg += 'Invalid SWaP';
+                break;
+            }
+        }
+    } else if (swp_msg_class === '02') {
+        msg += 'Payment';
+        msg += '|';
+        switch (swp_msg_type) {
+            case '01': {
+                msg += 'SLP Atomic Swap';
+                // TODO additional parsing
+                break;
+            }
+            case '02': {
+                msg += 'Multi-Party Escrow';
+                // TODO additional parsing
+                break;
+            }
+            case '03': {
+                msg += 'Threshold Crowdfunding';
+                // TODO additional parsing
+                break;
+            }
+            default: {
+                // Malformed SWaP tx
+                msg += 'Invalid SWaP';
+                break;
+            }
+        }
+    } else {
+        // Malformed SWaP tx
+        msg += 'Invalid SWaP';
+    }
+    return msg;
+};
+
+/**
+ * Build a string formatted for Telegram's API using HTML encoding
+ * @param {object} parsedBlock
+ * @param {array or false} coingeckoPrices if no coingecko API error
+ * @param {Map or false} tokenInfoMap if no chronik API error
+ * @param {Map or false} addressInfoMap if no chronik API error
+ * @returns {function} splitOverflowTgMsg(tgMsg)
+ */
+export const getBlockTgMessage = (
+    parsedBlock: HeraldParsedBlock,
+    coingeckoPrices: false | CoinGeckoPrice[],
+    tokenInfoMap: false | Map<string, GenesisInfo>,
+    outputScriptInfoMap: false | Map<string, OutputscriptInfo>,
+): string[] => {
+    const { hash, height, miner, staker, numTxs, parsedTxs } = parsedBlock;
+    const { emojis } = config;
+
+    // Define newsworthy types of txs in parsedTxs
+    // These arrays will be used to present txs in batches by type
+    const genesisTxTgMsgLines = [];
+    let cashtabTokenRewards = 0;
+    let cashtabXecRewardTxs = 0;
+    let cashtabXecRewardsTotalXec = 0;
+    const tokenSendTxTgMsgLines: string[] = [];
+    const tokenBurnTxTgMsgLines = [];
+    const opReturnTxTgMsgLines = [];
+    let xecSendTxTgMsgLines = [];
+
+    // We do not get that much newsworthy value from a long list of individual token send txs
+    // So, we organize token send txs by tokenId
+    const tokenSendTxMap = new Map();
+
+    // Iterate over parsedTxs to find anything newsworthy
+    for (let i = 0; i < parsedTxs.length; i += 1) {
+        const thisParsedTx = parsedTxs[i];
+        const {
+            txid,
+            genesisInfo,
+            opReturnInfo,
+            txFee,
+            xecSendingOutputScripts,
+            xecReceivingOutputs,
+            tokenSendInfo,
+            tokenBurnInfo,
+            totalSatsSent,
+        } = thisParsedTx;
+
+        if (genesisInfo && tokenInfoMap) {
+            // The txid of a genesis tx is the tokenId
+            const tokenId = txid;
+            const genesisInfoForThisToken = tokenInfoMap.get(tokenId);
+            let { tokenTicker, tokenName, url } = genesisInfoForThisToken!;
+            // Make sure tokenName does not contain telegram html escape characters
+            tokenName = prepareStringForTelegramHTML(tokenName);
+            // Make sure tokenName does not contain telegram html escape characters
+            tokenTicker = prepareStringForTelegramHTML(tokenTicker);
+            // Do not apply this parsing to tokenDocumentUrl, as this could change the URL
+            // If this breaks the msg, so be it
+            // Would only happen for bad URLs
+            genesisTxTgMsgLines.push(
+                `${emojis.tokenGenesis}<a href="${config.blockExplorer}/tx/${tokenId}">${tokenName}</a> (${tokenTicker}) <a href="${url}">[doc]</a>`,
+            );
+            // This parsed tx has a tg msg line. Move on to the next one.
+            continue;
+        }
+        if (opReturnInfo) {
+            let { app, msg, stackArray, tokenId } = opReturnInfo;
+            let appEmoji = '';
+
+            switch (app) {
+                case opReturn.memo.app: {
+                    appEmoji = emojis.memo;
+                    break;
+                }
+                case opReturn.knownApps.alias.app: {
+                    appEmoji = emojis.alias;
+                    break;
+                }
+                case opReturn.knownApps.payButton.app: {
+                    appEmoji = emojis.payButton;
+                    break;
+                }
+                case opReturn.knownApps.paywall.app: {
+                    appEmoji = emojis.paywall;
+                    break;
+                }
+                case opReturn.knownApps.authentication.app: {
+                    appEmoji = emojis.authentication;
+                    break;
+                }
+                case opReturn.knownApps.cashtabMsg.app: {
+                    appEmoji = emojis.cashtabMsg;
+
+                    const displayedSentAmount = satsToFormattedValue(
+                        totalSatsSent,
+                        coingeckoPrices,
+                    );
+
+                    const displayedTxFee = satsToFormattedValue(
+                        txFee,
+                        coingeckoPrices,
+                    );
+
+                    app += `, ${displayedSentAmount} for ${displayedTxFee}`;
+                    break;
+                }
+                case opReturn.knownApps.cashtabMsgEncrypted.app: {
+                    msg = getEncryptedCashtabMsg(
+                        cashaddr.encodeOutputScript(
+                            xecSendingOutputScripts.values().next().value!,
+                        ), // Assume first input is sender
+                        xecReceivingOutputs,
+                        totalSatsSent,
+                        coingeckoPrices,
+                    );
+                    appEmoji = emojis.cashtabEncrypted;
+                    break;
+                }
+                case opReturn.knownApps.airdrop.app: {
+                    msg = getAirdropTgMsg(
+                        stackArray!,
+                        cashaddr.encodeOutputScript(
+                            xecSendingOutputScripts.values().next().value!,
+                        ), // Assume first input is sender
+                        xecReceivingOutputs,
+                        totalSatsSent,
+                        tokenId && tokenInfoMap
+                            ? tokenInfoMap.get(tokenId)!
+                            : false,
+                        coingeckoPrices,
+                    );
+                    appEmoji = emojis.airdrop;
+                    break;
+                }
+                case opReturn.knownApps.swap.app: {
+                    msg = getSwapTgMsg(
+                        stackArray!,
+                        tokenId && tokenInfoMap
+                            ? tokenInfoMap.get(tokenId)!
+                            : false,
+                    );
+                    appEmoji = emojis.swap;
+                    break;
+                }
+                case opReturn.knownApps.fusion.app: {
+                    // totalSatsSent is total amount fused
+                    let displayedFusedQtyString = satsToFormattedValue(
+                        totalSatsSent,
+                        coingeckoPrices,
+                    );
+
+                    msg += `Fused ${displayedFusedQtyString} from ${xecSendingOutputScripts.size} inputs into ${xecReceivingOutputs.size} outputs`;
+                    appEmoji = emojis.fusion;
+                    break;
+                }
+                default: {
+                    appEmoji = emojis.unknown;
+                    break;
+                }
+            }
+
+            opReturnTxTgMsgLines.push(
+                `${appEmoji}<a href="${config.blockExplorer}/tx/${txid}">${app}:</a> ${msg}`,
+            );
+            // This parsed tx has a tg msg line. Move on to the next one.
+            continue;
+        }
+
+        if (tokenSendInfo && tokenInfoMap && !tokenBurnInfo) {
+            // If this is a token send tx that does not burn any tokens and you have tokenInfoMap
+            let { tokenId, tokenChangeOutputs, tokenReceivingOutputs } =
+                tokenSendInfo;
+
+            // Special handling for Cashtab rewards
+            if (
+                // CACHET token id
+                tokenId ===
+                    'aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1' &&
+                // outputScript of token-server
+                xecSendingOutputScripts.values().next().value ===
+                    TOKEN_SERVER_OUTPUTSCRIPT
+            ) {
+                cashtabTokenRewards += 1;
+                // No further parsing for this tx
+                continue;
+            }
+
+            // See if you already have info for txs from this token
+            const tokenSendTxInfo = tokenSendTxMap.get(tokenId);
+            if (typeof tokenSendTxInfo === 'undefined') {
+                // We don't have any other txs for this token, initialize an info object
+                // Get token info from tokenInfoMap
+                const thisTokenInfo = tokenInfoMap.get(tokenId);
+
+                let { tokenTicker, tokenName, decimals } = thisTokenInfo!;
+                // Note: tokenDocumentUrl and tokenDocumentHash are also available from thisTokenInfo
+
+                // Make sure tokenName does not contain telegram html escape characters
+                tokenName = prepareStringForTelegramHTML(tokenName);
+                // Make sure tokenName does not contain telegram html escape characters
+                tokenTicker = prepareStringForTelegramHTML(tokenTicker);
+
+                // Initialize token outputs (could be receiving or change depending on tx type)
+                let tokenOutputs =
+                    tokenReceivingOutputs!.size === 0
+                        ? tokenChangeOutputs
+                        : tokenReceivingOutputs;
+
+                let undecimalizedTokenReceivedAmount = new BigNumber(0);
+                for (const tokenReceivedAmount of tokenOutputs!.values()) {
+                    undecimalizedTokenReceivedAmount =
+                        undecimalizedTokenReceivedAmount.plus(
+                            tokenReceivedAmount,
+                        );
+                }
+
+                tokenSendTxMap.set(tokenId, {
+                    sendTxs: 1,
+                    tokenName,
+                    tokenTicker,
+                    decimals,
+                    undecimalizedTokenReceivedAmount,
+                });
+            } else {
+                // We do have other txs for this token, increment the tx count and amount sent
+                // Initialize token outputs (could be receiving or change depending on tx type)
+                let tokenOutputs =
+                    tokenReceivingOutputs!.size === 0
+                        ? tokenChangeOutputs
+                        : tokenReceivingOutputs;
+
+                let undecimalizedTokenReceivedAmount = new BigNumber(0);
+                for (const tokenReceivedAmount of tokenOutputs!.values()) {
+                    undecimalizedTokenReceivedAmount =
+                        undecimalizedTokenReceivedAmount.plus(
+                            tokenReceivedAmount,
+                        );
+                }
+
+                tokenSendTxMap.set(tokenId, {
+                    ...tokenSendTxInfo,
+                    sendTxs: tokenSendTxInfo.sendTxs + 1,
+                    undecimalizedTokenReceivedAmount:
+                        tokenSendTxInfo.undecimalizedTokenReceivedAmount.plus(
+                            undecimalizedTokenReceivedAmount,
+                        ),
+                });
+            }
+
+            // This parsed tx has info needed to build a tg msg line. Move on to the next one.
+            continue;
+        }
+
+        if (tokenBurnInfo && tokenInfoMap) {
+            // If this is a token burn tx and you have tokenInfoMap
+            const { tokenId, undecimalizedTokenBurnAmount } = tokenBurnInfo;
+
+            if (typeof tokenId !== 'undefined' && tokenInfoMap.has(tokenId)) {
+                // Some txs may have tokenBurnInfo, but did not get tokenSendInfo
+                // e.g. 0bb7e38d7f3968d3c91bba2d7b32273f203bc8b1b486633485f76dc7416a3eca
+                // This is a token burn tx but it is not indexed as such and requires more sophisticated burn parsing
+                // So, for now, just parse txs like this as XEC sends
+
+                // Get token info from tokenInfoMap
+                const thisTokenInfo = tokenInfoMap.get(tokenId);
+                let { tokenTicker, decimals } = thisTokenInfo!;
+
+                // Make sure tokenName does not contain telegram html escape characters
+                tokenTicker = prepareStringForTelegramHTML(tokenTicker);
+
+                // Calculate true tokenReceivedAmount using decimals
+                // Use decimals to calculate the burned amount as string
+                const decimalizedTokenBurnAmount =
+                    bigNumberAmountToLocaleString(
+                        undecimalizedTokenBurnAmount,
+                        decimals,
+                    );
+
+                const tokenBurningAddressStr = returnAddressPreview(
+                    cashaddr.encodeOutputScript(
+                        xecSendingOutputScripts.values().next().value!,
+                    ),
+                );
+
+                tokenBurnTxTgMsgLines.push(
+                    `${emojis.tokenBurn}${tokenBurningAddressStr} <a href="${config.blockExplorer}/tx/${txid}">burned</a> ${decimalizedTokenBurnAmount} <a href="${config.blockExplorer}/tx/${tokenId}">${tokenTicker}</a> `,
+                );
+
+                // This parsed tx has a tg msg line. Move on to the next one.
+                continue;
+            }
+        }
+
+        // Txs not parsed above are parsed as xec send txs
+
+        const displayedSentAmount = satsToFormattedValue(
+            totalSatsSent,
+            coingeckoPrices,
+        );
+
+        const displayedTxFee = satsToFormattedValue(txFee, coingeckoPrices);
+
+        // Clone xecReceivingOutputs so that you don't modify unit test mocks
+        let xecReceivingAddressOutputs = new Map(xecReceivingOutputs);
+
+        // Throw out OP_RETURN outputs for txs parsed as XEC send txs
+        xecReceivingAddressOutputs.forEach((value, key, map) => {
+            if (key.startsWith(opReturn.opReturnPrefix)) {
+                map.delete(key);
+            }
+        });
+
+        // Get address balance emojis for rendered addresses
+        // NB you are using xecReceivingAddressOutputs to avoid OP_RETURN outputScripts
+        let xecSenderEmoji = '';
+        let xecReceiverEmoji = '';
+
+        if (outputScriptInfoMap) {
+            // If you have information about address balances, get balance emojis
+            const firstXecSendingOutputScript = xecSendingOutputScripts
+                .values()
+                .next().value;
+
+            if (firstXecSendingOutputScript === TOKEN_SERVER_OUTPUTSCRIPT) {
+                cashtabXecRewardTxs += 1;
+                cashtabXecRewardsTotalXec += totalSatsSent;
+                continue;
+            }
+
+            const firstXecReceivingOutputScript = xecReceivingAddressOutputs
+                .keys()
+                .next().value;
+            const xecSenderInfoMap = outputScriptInfoMap.get(
+                firstXecSendingOutputScript!,
+            );
+            xecSenderEmoji =
+                typeof xecSenderInfoMap !== 'undefined'
+                    ? xecSenderInfoMap.emoji
+                    : '';
+            const xecReceiverInfoMap = outputScriptInfoMap.get(
+                firstXecReceivingOutputScript!,
+            );
+            xecReceiverEmoji =
+                typeof xecReceiverInfoMap !== 'undefined'
+                    ? xecReceiverInfoMap.emoji
+                    : '';
+        }
+
+        let xecSendMsg;
+        if (xecReceivingAddressOutputs.size === 0) {
+            // self send tx
+            // In this case, totalSatsSent has already been assigned to changeAmountSats
+
+            xecSendMsg = `${emojis.xecSend}<a href="${
+                config.blockExplorer
+            }/tx/${txid}">${displayedSentAmount} for ${displayedTxFee}</a>${
+                xecSenderEmoji !== ''
+                    ? ` ${xecSenderEmoji} ${
+                          xecSendingOutputScripts.size > 1
+                              ? `${xecSendingOutputScripts.size} addresses`
+                              : returnAddressPreview(
+                                    cashaddr.encodeOutputScript(
+                                        xecSendingOutputScripts.values().next()
+                                            .value!,
+                                    ),
+                                )
+                      } ${config.emojis.arrowRight} ${
+                          xecSendingOutputScripts.size > 1
+                              ? 'themselves'
+                              : 'itself'
+                      }`
+                    : ''
+            }`;
+        } else {
+            xecSendMsg = `${emojis.xecSend}<a href="${
+                config.blockExplorer
+            }/tx/${txid}">${displayedSentAmount} for ${displayedTxFee}</a>${
+                xecSenderEmoji !== '' || xecReceiverEmoji !== ''
+                    ? ` ${xecSenderEmoji}${returnAddressPreview(
+                          cashaddr.encodeOutputScript(
+                              xecSendingOutputScripts.values().next().value!,
+                          ),
+                      )} ${config.emojis.arrowRight} ${
+                          xecReceivingAddressOutputs.keys().next().value ===
+                          xecSendingOutputScripts.values().next().value
+                              ? 'itself'
+                              : `${xecReceiverEmoji}${returnAddressPreview(
+                                    cashaddr.encodeOutputScript(
+                                        xecReceivingAddressOutputs.keys().next()
+                                            .value!,
+                                    ),
+                                )}`
+                      }${
+                          xecReceivingAddressOutputs.size > 1
+                              ? ` and ${
+                                    xecReceivingAddressOutputs.size - 1
+                                } other${
+                                    xecReceivingAddressOutputs.size - 1 > 1
+                                        ? 's'
+                                        : ''
+                                }`
+                              : ''
+                      }`
+                    : ''
+            }`;
+        }
+
+        xecSendTxTgMsgLines.push(xecSendMsg);
+    }
+
+    // Build up message as an array, with each line as an entry
+    let tgMsg = [];
+
+    // Header
+    // <emojis.block><height> | <numTxs> | <miner>
+    tgMsg.push(
+        `${emojis.block}<a href="${
+            config.blockExplorer
+        }/block/${hash}">${height}</a> | ${numTxs} tx${
+            numTxs > 1 ? `s` : ''
+        } | ${miner}`,
+    );
+
+    // Halving countdown
+    const HALVING_HEIGHT = 840000;
+    const blocksLeft = HALVING_HEIGHT - height;
+    if (blocksLeft > 0) {
+        // countdown
+        tgMsg.push(
+            `⏰ ${blocksLeft.toLocaleString('en-US')} block${
+                blocksLeft !== 1 ? 's' : ''
+            } until eCash halving`,
+        );
+    }
+    if (height === HALVING_HEIGHT) {
+        tgMsg.push(`🎉🎉🎉 eCash block reward reduced by 50% 🎉🎉🎉`);
+    }
+
+    // Staker
+    // Staking rewards to <staker>
+    if (staker) {
+        // Get fiat amount of staking rwds
+        tgMsg.push(
+            `${emojis.staker}${satsToFormattedValue(
+                staker.reward,
+                coingeckoPrices,
+            )} to <a href="${config.blockExplorer}/address/${
+                staker.staker
+            }">${returnAddressPreview(staker.staker)}</a>`,
+        );
+    }
+
+    // Display prices as set in config.js
+    if (coingeckoPrices) {
+        // Iterate over prices and add a line for each price in the object
+
+        for (let i = 0; i < coingeckoPrices.length; i += 1) {
+            const { fiat, ticker, price } = coingeckoPrices[i];
+            const thisFormattedPrice = formatPrice(price, fiat);
+            tgMsg.push(`1 ${ticker} = ${thisFormattedPrice}`);
+        }
+    }
+
+    // Genesis txs
+    if (genesisTxTgMsgLines.length > 0) {
+        // Line break for new section
+        tgMsg.push('');
+
+        // 1 new eToken created:
+        // or
+        // <n> new eTokens created:
+        tgMsg.push(
+            `<b>${genesisTxTgMsgLines.length} new eToken${
+                genesisTxTgMsgLines.length > 1 ? `s` : ''
+            } created</b>`,
+        );
+
+        tgMsg = tgMsg.concat(genesisTxTgMsgLines);
+    }
+
+    // Cashtab rewards
+    if (cashtabTokenRewards > 0 || cashtabXecRewardTxs > 0) {
+        tgMsg.push('');
+        tgMsg.push(`<a href="https://cashtab.com/">Cashtab</a>`);
+        if (cashtabTokenRewards > 0) {
+            // 1 CACHET reward:
+            // or
+            // <n> CACHET rewards:
+            tgMsg.push(
+                `<b>${cashtabTokenRewards}</b> <a href="${
+                    config.blockExplorer
+                }/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">CACHET</a> reward${
+                    cashtabTokenRewards > 1 ? `s` : ''
+                }`,
+            );
+        }
+
+        // Cashtab XEC rewards
+        if (cashtabXecRewardTxs > 0) {
+            // 1 new user received 42 XEC
+            // or
+            // <n> new users received <...>
+            tgMsg.push(
+                `<b>${cashtabXecRewardTxs}</b> new user${
+                    cashtabXecRewardTxs > 1 ? `s` : ''
+                } received <b>${satsToFormattedValue(
+                    cashtabXecRewardsTotalXec,
+                )}</b>`,
+            );
+        }
+    }
+    if (tokenSendTxMap.size > 0) {
+        // eToken Send txs
+        // Line break for new section
+        tgMsg.push('');
+
+        // We include a 1-line summary for token send txs for each token ID
+        tokenSendTxMap.forEach((tokenSendInfo, tokenId) => {
+            const {
+                sendTxs,
+                tokenName,
+                tokenTicker,
+                decimals,
+                undecimalizedTokenReceivedAmount,
+            } = tokenSendInfo;
+
+            // Get decimalized receive amount
+            const decimalizedTokenReceivedAmount =
+                bigNumberAmountToLocaleString(
+                    undecimalizedTokenReceivedAmount.toString(),
+                    decimals,
+                );
+
+            tgMsg.push(
+                `${sendTxs} tx${
+                    sendTxs > 1 ? `s` : ''
+                } sent ${decimalizedTokenReceivedAmount} <a href="${
+                    config.blockExplorer
+                }/tx/${tokenId}">${tokenName} (${tokenTicker})</a>`,
+            );
+        });
+
+        tgMsg = tgMsg.concat(tokenSendTxTgMsgLines);
+    }
+
+    // eToken burn txs
+    if (tokenBurnTxTgMsgLines.length > 0) {
+        // Line break for new section
+        tgMsg.push('');
+
+        // 1 eToken burn tx:
+        // or
+        // <n> eToken burn txs:
+        tgMsg.push(
+            `<b>${tokenBurnTxTgMsgLines.length} eToken burn tx${
+                tokenBurnTxTgMsgLines.length > 1 ? `s` : ''
+            }</b>`,
+        );
+
+        tgMsg = tgMsg.concat(tokenBurnTxTgMsgLines);
+    }
+
+    // OP_RETURN txs
+    if (opReturnTxTgMsgLines.length > 0) {
+        // Line break for new section
+        tgMsg.push('');
+
+        // App txs
+        // or
+        // App tx
+        tgMsg.push(
+            `<b>${opReturnTxTgMsgLines.length} app tx${
+                opReturnTxTgMsgLines.length > 1 ? `s` : ''
+            }</b>`,
+        );
+
+        // <appName> : <parsedAppData>
+        // alias: newlyregisteredalias
+        // Cashtab Msg: This is a Cashtab Msg
+        tgMsg = tgMsg.concat(opReturnTxTgMsgLines);
+    }
+
+    // XEC txs
+    const totalXecSendCount = xecSendTxTgMsgLines.length;
+    if (totalXecSendCount > 0) {
+        // Line break for new section
+        tgMsg.push('');
+
+        // Don't show more than config-adjustable amount of these txs
+        if (totalXecSendCount > config.xecSendDisplayCount) {
+            xecSendTxTgMsgLines = xecSendTxTgMsgLines.slice(
+                0,
+                config.xecSendDisplayCount,
+            );
+            xecSendTxTgMsgLines.push(
+                `...and <a href="${config.blockExplorer}/block/${hash}">${
+                    totalXecSendCount - config.xecSendDisplayCount
+                } more</a>`,
+            );
+        }
+        // 1 eCash tx
+        // or
+        // n eCash txs
+        tgMsg.push(
+            `<b>${totalXecSendCount} eCash tx${
+                totalXecSendCount > 1 ? `s` : ''
+            }</b>`,
+        );
+
+        tgMsg = tgMsg.concat(xecSendTxTgMsgLines);
+    }
+
+    return splitOverflowTgMsg(tgMsg);
+};
+
+/**
+ * Guess the reason why an block was invalidated by avalanche
+ * @param {ChronikClient} chronik
+ * @param {number} blockHeight
+ * @param {object} coinbaseData
+ * @param {object} memoryCache
+ * @returns {string} reason
+ */
+export const guessRejectReason = async (
+    chronik: ChronikClient,
+    blockHeight: number,
+    coinbaseData: CoinbaseData,
+    memoryCache: MemoryCache,
+): Promise<string | undefined> => {
+    // Let's guess the reject reason by looking for the common cases in order:
+    //  1. Missing the miner fund output
+    //  2. Missing the staking reward output
+    //  3. Wrong staking reward winner
+    //  4. Normal orphan (another block exists at the same height)
+    //  5. RTT rejection
+    if (typeof coinbaseData === 'undefined') {
+        return undefined;
+    }
+
+    // 1. Missing the miner fund output
+    // This output is a constant so it's easy to look for
+    let hasMinerFundOuptut = false;
+    for (let i = 0; i < coinbaseData.outputs.length; i += 1) {
+        if (coinbaseData.outputs[i].outputScript === minerFundOutputScript) {
+            hasMinerFundOuptut = true;
+            break;
+        }
+    }
+    if (!hasMinerFundOuptut) {
+        return 'missing miner fund output';
+    }
+
+    // 2. Missing the staking reward output
+    // We checked for missing miner fund output already, so if there are
+    // fewer than 3 outputs we are sure the staking reward is missing
+    if (coinbaseData.outputs.length < 3) {
+        return 'missing staking reward output';
+    }
+
+    // 3. Wrong staking reward winner
+    const expectedWinner: undefined | { address: string; scriptHex: string } =
+        await memoryCache.get(`${blockHeight}`);
+    // We might have failed to fetch the expected winner for this block, in
+    // which case we can't determine if staking reward is the likely cause.
+    if (typeof expectedWinner !== 'undefined') {
+        const { address, scriptHex } = expectedWinner;
+
+        let stakingRewardOutputIndex = -1;
+        for (let i = 0; i < coinbaseData.outputs.length; i += 1) {
+            if (coinbaseData.outputs[i].outputScript === scriptHex) {
+                stakingRewardOutputIndex = i;
+                break;
+            }
+        }
+
+        // We didn't find the expected staking reward output
+        if (stakingRewardOutputIndex < 0) {
+            const wrongWinner = getStakerFromCoinbaseTx(
+                blockHeight,
+                coinbaseData.outputs,
+            );
+
+            if (wrongWinner !== false) {
+                // Try to show the eCash address and fallback to script hex
+                // if it is not possible.
+                if (typeof address !== 'undefined') {
+                    try {
+                        const wrongWinnerAddress = cashaddr.encodeOutputScript(
+                            wrongWinner.staker,
+                        );
+                        return `wrong staking reward payout (${wrongWinnerAddress} instead of ${address})`;
+                    } catch (err) {
+                        // Fallthrough
+                    }
+                }
+
+                return `wrong staking reward payout (${wrongWinner.staker} instead of ${scriptHex})`;
+            }
+        }
+    }
+
+    // 4. Normal orphan (another block exists at the same height)
+    // If chronik returns a block at the same height, assume it orphaned
+    // the current invalidated block. It's very possible the block is not
+    // finalized yet so we have no better way to check it's actually what
+    // happened.
+    try {
+        const blockAtSameHeight = await chronik.block(blockHeight);
+        return `orphaned by block ${blockAtSameHeight.blockInfo.hash}`;
+    } catch (err) {
+        // Block not found, keep guessing
+    }
+
+    // 5. RTT rejection
+    // FIXME There is currently no way to determine if the block was
+    // rejected due to RTT violation.
+
+    return 'unknown';
+};
+
+/**
+ * Summarize an arbitrary array of chronik txs
+ * Different logic vs "per block" herald msgs, as we are looking to
+ * get meaningful info from more txs
+ * We are interested in what txs were like over a certain time period
+ * Not details of a particular block
+ *
+ * TODO
+ * Biggest tx
+ * Highest fee
+ * Token dex volume
+ * Biggest token sales
+ * Whale alerts
+ *
+ * @param now unix timestamp in seconds
+ * @param txs array of CONFIRMED Txs
+ * @param tokenInfoMap tokenId => genesisInfo
+ * @param priceInfo { usd, usd_market_cap, usd_24h_vol, usd_24h_change }
+ */
+export const summarizeTxHistory = (
+    now: number,
+    txs: Tx[],
+    tokenInfoMap: false | Map<string, GenesisInfo>,
+    priceInfo?: PriceInfo,
+): string[] => {
+    const xecPriceUsd =
+        typeof priceInfo !== 'undefined' ? priceInfo.usd : undefined;
+    // Throw out any unconfirmed txs
+    txs.filter(tx => typeof tx.block !== 'undefined');
+
+    // Sort by blockheight
+    txs.sort((a, b) => a.block!.height - b.block!.height);
+    const txCount = txs.length;
+    // Get covered blocks
+    // Note we add 1 as we include the block at index 0
+    const blockCount =
+        txs[txCount - 1].block!.height - txs[0].block!.height + 1;
+
+    // Initialize objects useful for summarizing data
+
+    // miner => blocks found
+    const minerMap = new Map();
+
+    // miner pools where we can parse individual miners
+    let viaBtcBlocks = 0;
+    const viabtcMinerMap = new Map();
+
+    // stakerOutputScript => {count, reward}
+    const stakerMap = new Map();
+
+    // TODO more info about send txs
+    // inputs[0].outputScript => {count, satoshisSent}
+    // senderMap
+
+    // lokad name => count
+    const appTxMap = new Map();
+
+    let totalStakingRewardSats = 0;
+    let cashtabXecRewardCount = 0;
+    let cashtabXecRewardSats = 0;
+    let cashtabCachetRewardCount = 0;
+    let binanceWithdrawalCount = 0;
+    let binanceWithdrawalSats = 0;
+
+    let slpFungibleTxs = 0;
+    let appTxs = 0;
+    let unknownLokadTxs = 0;
+
+    // tokenId => {info, list, cancel, buy, adPrep, send, burn, mint, genesis: {genesisQty: <>, hasBaton: <>}}
+    const tokenActions = new Map();
+    let invalidTokenEntries = 0;
+    let nftNonAgoraTokenEntries = 0;
+    let mintVaultTokenEntries = 0;
+    let alpTokenEntries = 0;
+
+    let newSlpTokensFixedSupply = 0;
+    let newSlpTokensVariableSupply = 0;
+
+    // Nft vars
+    const nftActions = new Map();
+    const nftAgoraActions = new Map();
+    const uniqueAgoraNfts = new Set();
+    const uniqueNonAgoraNfts = new Set();
+    let agoraOneshotTxs = 0;
+    let nftMints = 0;
+
+    // Agora vars
+    let agoraTxs = 0;
+    const agoraActions = new Map();
+
+    for (const tx of txs) {
+        const { inputs, outputs, block, tokenEntries, isCoinbase } = tx;
+
+        if (isCoinbase) {
+            // Coinbase tx - get miner and staker info
+            const miner = getMinerFromCoinbaseTx(
+                tx.inputs[0].inputScript,
+                outputs,
+                miners,
+            );
+            if (miner.includes('ViaBTC')) {
+                viaBtcBlocks += 1;
+                // ViaBTC pool miner
+                let blocksFoundThisViaMiner = viabtcMinerMap.get(miner);
+                if (typeof blocksFoundThisViaMiner === 'undefined') {
+                    viabtcMinerMap.set(miner, 1);
+                } else {
+                    viabtcMinerMap.set(miner, blocksFoundThisViaMiner + 1);
+                }
+            } else {
+                // Other miner
+                let blocksFoundThisMiner = minerMap.get(miner);
+                if (typeof blocksFoundThisMiner === 'undefined') {
+                    minerMap.set(miner, 1);
+                } else {
+                    minerMap.set(miner, blocksFoundThisMiner + 1);
+                }
+            }
+
+            const stakerInfo = getStakerFromCoinbaseTx(block!.height, outputs);
+            if (stakerInfo) {
+                // The coinbase tx may have no staker
+                // In thise case, we do not have any staking info to update
+
+                const { staker, reward } = stakerInfo;
+
+                totalStakingRewardSats += reward;
+
+                let stakingRewardsThisStaker = stakerMap.get(staker);
+                if (typeof stakingRewardsThisStaker === 'undefined') {
+                    stakerMap.set(staker, { count: 1, reward });
+                } else {
+                    stakingRewardsThisStaker.reward += reward;
+                    stakingRewardsThisStaker.count += 1;
+                }
+            }
+            // No further analysis for this tx
+            continue;
+        }
+        const senderOutputScript = inputs[0].outputScript;
+        if (senderOutputScript === TOKEN_SERVER_OUTPUTSCRIPT) {
+            // If this tx was sent by token-server
+            if (tokenEntries.length > 0) {
+                // We assume all token txs sent by token-server are CACHET rewards
+                // CACHET reward
+                cashtabCachetRewardCount += 1;
+            } else {
+                // XEC rwd
+                cashtabXecRewardCount += 1;
+                for (const output of outputs) {
+                    const { value, outputScript } = output;
+                    if (outputScript !== TOKEN_SERVER_OUTPUTSCRIPT) {
+                        cashtabXecRewardSats += value;
+                    }
+                }
+            }
+            // No further analysis for this tx
+            continue;
+        }
+        if (senderOutputScript === BINANCE_OUTPUTSCRIPT) {
+            // Tx sent by Binance
+            // Make sure it's not just a utxo consolidation
+            for (const output of outputs) {
+                const { value, outputScript } = output;
+                if (outputScript !== BINANCE_OUTPUTSCRIPT) {
+                    // If we have an output that is not sending to the binance hot wallet
+                    // Increment total value amount withdrawn
+                    binanceWithdrawalSats += value;
+                    // We also call this a withdrawal
+                    // Note that 1 tx from the hot wallet may include more than 1 withdrawal
+                    binanceWithdrawalCount += 1;
+                }
+            }
+        }
+
+        // Other token actions
+        if (tokenEntries.length > 0) {
+            for (const tokenEntry of tokenEntries) {
+                // Get the tokenId
+                // Note that groupTokenId is only defined for NFT child
+                const {
+                    tokenId,
+                    tokenType,
+                    txType,
+                    groupTokenId,
+                    isInvalid,
+                    actualBurnAmount,
+                } = tokenEntry;
+                const { type } = tokenType;
+
+                if (isInvalid) {
+                    // TODO find this for test tx
+                    invalidTokenEntries += 1;
+                    // Log to console so if we see this tx, we can analyze it for parsing
+                    console.info(
+                        `Unparsed isInvalid tokenEntry in tx: ${tx.txid}`,
+                    );
+                    // No other parsing for this tokenEntry
+                    continue;
+                }
+
+                if (type === 'ALP_TOKEN_TYPE_STANDARD') {
+                    // TODO ALP parsing
+                    alpTokenEntries += 1;
+                    // Log to console so if we see this tx, we can analyze it for parsing
+                    console.info(
+                        `Unparsed ALP_TOKEN_TYPE_STANDARD tokenEntry in tx: ${tx.txid}`,
+                    );
+                    // No other parsing for this tokenEntry
+                    continue;
+                }
+
+                if (type === 'SLP_TOKEN_TYPE_MINT_VAULT') {
+                    // TODO mint valt parsing
+                    mintVaultTokenEntries += 1;
+                    // Log to console so if we see this tx, we can analyze it for parsing
+                    console.info(
+                        `Unparsed SLP_TOKEN_TYPE_MINT_VAULT tokenEntry in tx: ${tx.txid}`,
+                    );
+                    // No other parsing for this tokenEntry
+                    continue;
+                }
+
+                if (type === 'SLP_TOKEN_TYPE_NFT1_CHILD') {
+                    if (typeof groupTokenId === 'undefined') {
+                        // Should never happen
+                        invalidTokenEntries += 1;
+                        // Log to console so if we see this tx, we can analyze it for parsing
+                        console.info(
+                            `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD with undefined groupTokenId: ${tx.txid}`,
+                        );
+                        // No other parsing for this tokenEntry
+                        continue;
+                    }
+                    // Note that we organize all NFT1 children by their collection for herald purposes
+                    // Parse NFT child tx
+
+                    switch (txType) {
+                        case 'NONE': {
+                            invalidTokenEntries += 1;
+                            // Log to console so if we see this tx, we can analyze it for parsing
+                            console.info(
+                                `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD txType NONE tokenEntry in tx: ${tx.txid}`,
+                            );
+                            // No other parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'UNKNOWN': {
+                            invalidTokenEntries += 1;
+                            // Log to console so if we see this tx, we can analyze it for parsing
+                            console.info(
+                                `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD txType UNKNOWN tokenEntry in tx: ${tx.txid}`,
+                            );
+                            // No other parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'GENESIS': {
+                            // NFT1 NFTs have special genesis, in that they burn 1 of the group
+                            // their txType is still genesis
+                            // For the herald, these are better represented as "NFT mints" than
+                            // "NFT1 Child Genesis"
+                            // But coding side, we organize them this way
+                            nftMints += 1;
+                            nftNonAgoraTokenEntries += 1;
+
+                            // See if we already have tokenActions at this tokenId
+                            const existingNftActions =
+                                nftActions.get(groupTokenId);
+                            initializeOrIncrementTokenData(
+                                nftActions,
+                                existingNftActions,
+                                groupTokenId,
+                                TrackedTokenAction.Genesis,
+                            );
+                            uniqueNonAgoraNfts.add(tokenId);
+                            // No further parsing for this token entry
+                            continue;
+                        }
+                        case 'SEND': {
+                            // SEND may be Agora ONESHOT or Burn
+                            const existingNftActions =
+                                nftActions.get(groupTokenId);
+                            const existingNftAgoraActions =
+                                nftAgoraActions.get(groupTokenId);
+
+                            // For now, we assume that any p2sh token input is agora buy/cancel
+                            // and any p2sh token output is an ad setup tx
+                            // No other known cases of p2sh for token txs on ecash today
+                            // tho multisig is possible, no supporting wallets
+
+                            let isAgoraBuySellList = false;
+                            for (const input of inputs) {
+                                if (typeof input.token !== 'undefined') {
+                                    const { outputScript, inputScript } = input;
+                                    // A token input that is p2sh may be
+                                    // a listing, an ad setup, a buy, or a cancel
+                                    try {
+                                        const { type } =
+                                            cashaddr.getTypeAndHashFromOutputScript(
+                                                outputScript!,
+                                            );
+                                        if (type === 'p2sh') {
+                                            // Note that a ONESHOT agora tx does not necessarily
+                                            // have 0441475230 in the inputscript
+                                            // But we do not have any other p2sh token input txs, so parse
+
+                                            // Agora tx
+                                            // For now, we know all listing txs only have a single p2sh input
+
+                                            if (inputs.length === 1) {
+                                                // Agora ONESHOT listing in collection groupTokenId
+                                                initializeOrIncrementTokenData(
+                                                    nftAgoraActions,
+                                                    existingNftAgoraActions,
+                                                    groupTokenId,
+                                                    TrackedTokenAction.List,
+                                                );
+                                                isAgoraBuySellList = true;
+                                                // Stop processing inputs for this tx
+                                                break;
+                                            }
+                                            // Check if this is a cancellation
+                                            // See agora.ts from ecash-agora lib
+                                            // For now, I don't think it makes sense to have an 'isCanceled' method from ecash-agora
+                                            // This is a pretty specific application
+                                            const ops = scriptOps(
+                                                new Script(
+                                                    fromHex(inputScript),
+                                                ),
+                                            );
+                                            // isCanceled is always the last pushop (before redeemScript)
+                                            const opIsCanceled =
+                                                ops[ops.length - 2];
+
+                                            const isCanceled =
+                                                opIsCanceled === OP_0;
+
+                                            if (isCanceled) {
+                                                // Agora ONESHOT cancel in collection groupTokenId
+                                                initializeOrIncrementTokenData(
+                                                    nftAgoraActions,
+                                                    existingNftAgoraActions,
+                                                    groupTokenId,
+                                                    TrackedTokenAction.Cancel,
+                                                );
+                                                isAgoraBuySellList = true;
+                                                // Stop processing inputs for this tx
+                                                break;
+                                            } else {
+                                                // Agora ONESHOT purchase
+                                                initializeOrIncrementTokenData(
+                                                    nftAgoraActions,
+                                                    existingNftAgoraActions,
+                                                    groupTokenId,
+                                                    TrackedTokenAction.Buy,
+                                                );
+                                                isAgoraBuySellList = true;
+                                                // Stop processing inputs for this tx
+                                                break;
+                                            }
+                                        }
+                                    } catch (err) {
+                                        console.error(
+                                            `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) from txid ${tx.txid}`,
+                                        );
+                                        // Do not parse it as an agora tx
+                                    }
+                                    // We don't need to find any other inputs for this case
+                                    break;
+                                }
+                            }
+                            if (isAgoraBuySellList) {
+                                agoraOneshotTxs += 1;
+                                uniqueAgoraNfts.add(tokenId);
+                                // We have already processed this token tx
+                                continue;
+                            }
+
+                            // Check for ad prep tx
+                            let isAdPrep = false;
+                            for (const output of outputs) {
+                                if (typeof output.token !== 'undefined') {
+                                    const { outputScript } = output;
+                                    // We assume a p2sh token output is an ad setup tx
+                                    // No other known use cases at the moment
+                                    try {
+                                        const { type } =
+                                            cashaddr.getTypeAndHashFromOutputScript(
+                                                outputScript,
+                                            );
+
+                                        if (type === 'p2sh') {
+                                            // Agora ONESHOT ad setup tx for collection groupTokenId
+                                            initializeOrIncrementTokenData(
+                                                nftAgoraActions,
+                                                existingNftAgoraActions,
+                                                groupTokenId,
+                                                TrackedTokenAction.AdPrep,
+                                            );
+                                            isAdPrep = true;
+                                            break;
+                                            // Stop iterating over outputs
+                                        }
+                                    } catch (err) {
+                                        console.error(
+                                            `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) for output from txid ${tx.txid}`,
+                                        );
+                                        // Do not parse it as an agora tx
+                                    }
+                                }
+                            }
+                            if (isAdPrep) {
+                                agoraOneshotTxs += 1;
+                                uniqueAgoraNfts.add(tokenId);
+                                // We have processed this tx as an Agora Ad setup tx
+                                // No further processing
+                                continue;
+                            }
+
+                            if (actualBurnAmount !== '0') {
+                                nftNonAgoraTokenEntries += 1;
+                                // Parse as burn
+                                // Note this is not currently supported in Cashtab
+                                initializeOrIncrementTokenData(
+                                    nftActions,
+                                    existingNftActions,
+                                    groupTokenId,
+                                    TrackedTokenAction.Burn,
+                                );
+                                uniqueNonAgoraNfts.add(tokenId);
+                                // No further parsing
+                                continue;
+                            }
+
+                            // Parse as send
+                            initializeOrIncrementTokenData(
+                                nftActions,
+                                existingNftActions,
+                                groupTokenId,
+                                TrackedTokenAction.Send,
+                            );
+                            nftNonAgoraTokenEntries += 1;
+                            uniqueNonAgoraNfts.add(tokenId);
+                            // No further parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'MINT': {
+                            // We do not expect to see any MINT txs for NFT1 children
+                            // Some confusion as what crypto colloquially calls an "NFT Mint"
+                            // is NOT this type of mint, but a genesis tx
+                            // Run the map anyway in case we get it
+                            invalidTokenEntries += 1;
+                            // Log to console so if we see this tx, we can analyze it for parsing
+                            console.info(
+                                `Unparsed SLP_TOKEN_TYPE_NFT1_CHILD txType MINT tokenEntry in tx: ${tx.txid}`,
+                            );
+                            const existingNftActions =
+                                nftActions.get(groupTokenId);
+                            initializeOrIncrementTokenData(
+                                nftActions,
+                                existingNftActions,
+                                tokenId,
+                                TrackedTokenAction.Mint,
+                            );
+                            uniqueNonAgoraNfts.add(tokenId);
+                            // No further parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'BURN': {
+                            const existingNftActions = nftActions.get(tokenId);
+                            initializeOrIncrementTokenData(
+                                nftActions,
+                                existingNftActions,
+                                tokenId,
+                                TrackedTokenAction.Burn,
+                            );
+                            nftNonAgoraTokenEntries += 1;
+                            uniqueNonAgoraNfts.add(tokenId);
+                            // No further parsing for this tokenEntry
+                            continue;
+                        }
+                        default:
+                            // Can we get here?
+                            // Log for analysis if it happens
+                            invalidTokenEntries += 1;
+                            console.info(
+                                `Switch default token action for SLP_TOKEN_TYPE_NFT1_CHILD in tx: ${tx.txid}`,
+                            );
+                            // No further analysis this tokenEntry
+                            continue;
+                    }
+                }
+                if (type === 'SLP_TOKEN_TYPE_FUNGIBLE') {
+                    slpFungibleTxs += 1;
+                    switch (txType) {
+                        case 'NONE': {
+                            invalidTokenEntries += 1;
+                            // Log to console so if we see this tx, we can analyze it for parsing
+                            console.info(
+                                `Unparsed SLP_TOKEN_TYPE_FUNGIBLE txType NONE tokenEntry in tx: ${tx.txid}`,
+                            );
+                            // No other parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'UNKNOWN': {
+                            invalidTokenEntries += 1;
+                            // Log to console so if we see this tx, we can analyze it for parsing
+                            console.info(
+                                `Unparsed SLP_TOKEN_TYPE_FUNGIBLE txType UNKNOWN tokenEntry in tx: ${tx.txid}`,
+                            );
+                            // No other parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'GENESIS': {
+                            const genesis = {
+                                amount: '0',
+                                hasBaton: false,
+                            };
+                            // See if we already have tokenActions at this tokenId
+                            const existingActions = tokenActions.get(tokenId);
+                            for (const output of outputs) {
+                                if (typeof output.token !== 'undefined') {
+                                    if (output.token.tokenId === tokenId) {
+                                        // Per spec, SLP 1 genesis qty is always at output index 1
+                                        // But we iterate over all outputs to check for mint batons
+                                        const { amount, isMintBaton } =
+                                            output.token;
+                                        if (isMintBaton) {
+                                            newSlpTokensVariableSupply += 1;
+                                            genesis.hasBaton = true;
+                                        } else {
+                                            newSlpTokensFixedSupply += 1;
+                                            genesis.amount = amount;
+                                        }
+                                    }
+                                    // We do not use initializeOrIncrementTokenData here
+                                    // genesis does not follow the same structure
+                                    // Count is not important but we have more info for genesis
+                                    tokenActions.set(
+                                        tokenId,
+                                        typeof existingActions === 'undefined'
+                                            ? { genesis, actionCount: 1 }
+                                            : {
+                                                  ...existingActions,
+                                                  genesis,
+                                                  actionCount:
+                                                      existingActions.actionCount +
+                                                      1,
+                                              },
+                                    );
+                                    // No further parsing for this tokenEntry
+                                    continue;
+                                }
+                            }
+                            break;
+                        }
+                        case 'SEND': {
+                            // SEND may be Agora or Burn
+                            const existingTokenActions =
+                                tokenActions.get(tokenId);
+                            const existingAgoraActions =
+                                agoraActions.get(tokenId);
+
+                            // For now, we assume that any p2sh token input is agora buy/cancel
+                            // and any p2sh token output is an ad setup tx
+                            // No other known cases of p2sh for token txs on ecash today
+                            // tho multisig is possible, no supporting wallets
+
+                            // mb parse for ad setup first, which is p2sh output?
+
+                            let isAgoraBuySellList = false;
+                            for (const input of inputs) {
+                                if (typeof input.token !== 'undefined') {
+                                    const { outputScript, inputScript } = input;
+                                    // A token input that is p2sh may be
+                                    // a listing, an ad setup, a buy, or a cancel
+                                    try {
+                                        const { type } =
+                                            cashaddr.getTypeAndHashFromOutputScript(
+                                                outputScript!,
+                                            );
+                                        if (type === 'p2sh') {
+                                            // We are only parsing SLP agora txs here
+                                            // A listing will have AGR0 lokad in input script
+                                            const AGORA_LOKAD_STARTSWITH =
+                                                '0441475230';
+
+                                            if (
+                                                inputScript.startsWith(
+                                                    AGORA_LOKAD_STARTSWITH,
+                                                )
+                                            ) {
+                                                // Agora tx
+                                                // For now, we know all listing txs only have a single p2sh input
+
+                                                if (inputs.length === 1) {
+                                                    // Agora listing
+                                                    initializeOrIncrementTokenData(
+                                                        agoraActions,
+                                                        existingAgoraActions,
+                                                        tokenId,
+                                                        TrackedTokenAction.List,
+                                                    );
+                                                    isAgoraBuySellList = true;
+                                                    // Stop processing inputs for this tx
+                                                    break;
+                                                }
+                                                // Check if this is a cancellation
+                                                // See agora.ts from ecash-agora lib
+                                                // For now, I don't think it makes sense to have an 'isCanceled' method from ecash-agora
+                                                // This is a pretty specific application
+                                                const ops = scriptOps(
+                                                    new Script(
+                                                        fromHex(inputScript),
+                                                    ),
+                                                );
+                                                // isCanceled is always the last pushop (before redeemScript)
+                                                const opIsCanceled =
+                                                    ops[ops.length - 2];
+
+                                                const isCanceled =
+                                                    opIsCanceled === OP_0;
+
+                                                if (isCanceled) {
+                                                    // Agora cancel
+                                                    initializeOrIncrementTokenData(
+                                                        agoraActions,
+                                                        existingAgoraActions,
+                                                        tokenId,
+                                                        TrackedTokenAction.Cancel,
+                                                    );
+                                                    isAgoraBuySellList = true;
+                                                    // Stop processing inputs for this tx
+                                                    break;
+                                                } else {
+                                                    // Agora purchase
+                                                    initializeOrIncrementTokenData(
+                                                        agoraActions,
+                                                        existingAgoraActions,
+                                                        tokenId,
+                                                        TrackedTokenAction.Buy,
+                                                    );
+                                                    isAgoraBuySellList = true;
+                                                    // Stop processing inputs for this tx
+                                                    break;
+                                                }
+                                            }
+                                        }
+                                    } catch (err) {
+                                        console.error(
+                                            `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) from txid ${tx.txid}`,
+                                        );
+                                        // Do not parse it as an agora tx
+                                    }
+                                    // We don't need to find any other inputs for this case
+                                    break;
+                                }
+                            }
+                            if (isAgoraBuySellList) {
+                                agoraTxs += 1;
+                                // We have already processed this token tx
+                                continue;
+                            }
+
+                            // Check for ad prep tx
+                            let isAdPrep = false;
+                            for (const output of outputs) {
+                                if (typeof output.token !== 'undefined') {
+                                    const { outputScript } = output;
+                                    // We assume a p2sh token output is an ad setup tx
+                                    // No other known use cases at the moment
+                                    try {
+                                        const { type } =
+                                            cashaddr.getTypeAndHashFromOutputScript(
+                                                outputScript,
+                                            );
+                                        if (type === 'p2sh') {
+                                            // Agora ad setup tx for SLP1
+                                            initializeOrIncrementTokenData(
+                                                agoraActions,
+                                                existingAgoraActions,
+                                                tokenId,
+                                                TrackedTokenAction.AdPrep,
+                                            );
+                                            isAdPrep = true;
+                                            break;
+                                            // Stop iterating over outputs
+                                        }
+                                    } catch (err) {
+                                        console.error(
+                                            `Error in cashaddr.getTypeAndHashFromOutputScript(${outputScript}) for output from txid ${tx.txid}`,
+                                        );
+                                        // Do not parse it as an agora tx
+                                    }
+                                }
+                            }
+                            if (isAdPrep) {
+                                agoraTxs += 1;
+                                // We have processed this tx as an Agora Ad setup tx
+                                // No further processing
+                                continue;
+                            }
+
+                            // Parse as burn
+                            if (actualBurnAmount !== '0') {
+                                initializeOrIncrementTokenData(
+                                    tokenActions,
+                                    existingTokenActions,
+                                    tokenId,
+                                    TrackedTokenAction.Burn,
+                                );
+                                // No further parsing
+                                continue;
+                            }
+
+                            // Parse as send
+                            initializeOrIncrementTokenData(
+                                tokenActions,
+                                existingTokenActions,
+                                tokenId,
+                                TrackedTokenAction.Send,
+                            );
+                            // No further parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'MINT': {
+                            const existingTokenActions =
+                                tokenActions.get(tokenId);
+                            initializeOrIncrementTokenData(
+                                tokenActions,
+                                existingTokenActions,
+                                tokenId,
+                                TrackedTokenAction.Mint,
+                            );
+                            // No further parsing for this tokenEntry
+                            continue;
+                        }
+                        case 'BURN': {
+                            const existingTokenActions =
+                                tokenActions.get(tokenId);
+                            initializeOrIncrementTokenData(
+                                tokenActions,
+                                existingTokenActions,
+                                tokenId,
+                                TrackedTokenAction.Burn,
+                            );
+                            // No further parsing for this tokenEntry
+                            continue;
+                        }
+                        default:
+                            // Can we get here?
+                            // Log for analysis if it happens
+                            invalidTokenEntries += 1;
+                            console.info(
+                                `Switch default token action in tx: ${tx.txid}`,
+                            );
+                            // No further analysis this tokenEntry
+                            continue;
+                    }
+                }
+            }
+
+            // No further action this tx
+            continue;
+        }
+        const firstOutputScript = outputs[0].outputScript;
+        const LOKAD_OPRETURN_STARTSWITH = '6a04';
+        if (firstOutputScript.startsWith(LOKAD_OPRETURN_STARTSWITH)) {
+            appTxs += 1;
+            // We only parse minimally-pushed lokad ids
+
+            // Get the lokadId (the 4-byte first push)
+            const lokadId = firstOutputScript.slice(4, 12);
+
+            // Add to map
+            const countThisLokad = appTxMap.get(lokadId);
+            appTxMap.set(
+                lokadId,
+                typeof countThisLokad === 'undefined' ? 1 : countThisLokad + 1,
+            );
+        }
+    }
+
+    // Add ViaBTC as a single entity to minerMap
+    minerMap.set(`ViaBTC`, viaBtcBlocks);
+    // Sort miner map by blocks found
+    const sortedMinerMap = new Map(
+        [...minerMap.entries()].sort(
+            (keyValueArrayA, keyValueArrayB) =>
+                keyValueArrayB[1] - keyValueArrayA[1],
+        ),
+    );
+    const sortedStakerMap = new Map(
+        [...stakerMap.entries()].sort(
+            (keyValueArrayA, keyValueArrayB) =>
+                keyValueArrayB[1].count - keyValueArrayA[1].count,
+        ),
+    );
+
+    // Build your msg
+    const tgMsg = [];
+
+    tgMsg.push(
+        `<b>${new Date(now * 1000).toLocaleDateString('en-GB', {
+            year: 'numeric',
+            month: 'short',
+            day: 'numeric',
+            timeZone: 'UTC',
+        })}</b>`,
+    );
+    tgMsg.push(
+        `${config.emojis.block}${blockCount.toLocaleString('en-US')} blocks`,
+    );
+    tgMsg.push(
+        `${config.emojis.arrowRight}${txs.length.toLocaleString('en-US')} txs`,
+    );
+    tgMsg.push('');
+
+    // Market summary
+    if (typeof priceInfo !== 'undefined') {
+        const { usd_market_cap, usd_24h_vol, usd_24h_change } = priceInfo;
+        tgMsg.push(
+            `${
+                usd_24h_change > 0
+                    ? config.emojis.priceUp
+                    : config.emojis.priceDown
+            }<b>1 XEC = ${formatPrice(
+                xecPriceUsd!,
+                'usd',
+            )}</b> <i>(${usd_24h_change.toFixed(2)}%)</i>`,
+        );
+        tgMsg.push(
+            `Trading volume: $${usd_24h_vol.toLocaleString('en-US', {
+                maximumFractionDigits: 0,
+            })}`,
+        );
+        tgMsg.push(
+            `Market cap: $${usd_market_cap.toLocaleString('en-US', {
+                maximumFractionDigits: 0,
+            })}`,
+        );
+        tgMsg.push('');
+    }
+
+    // Top miners
+    const MINERS_TO_SHOW = 3;
+    tgMsg.push(
+        `<b><i>${config.emojis.miner}${sortedMinerMap.size} miners found blocks</i></b>`,
+    );
+    tgMsg.push(`<u>Top ${MINERS_TO_SHOW}</u>`);
+
+    const topMiners = [...sortedMinerMap.entries()].slice(0, MINERS_TO_SHOW);
+    for (let i = 0; i < topMiners.length; i += 1) {
+        const count = topMiners[i][1];
+        const pct = (100 * (count / blockCount)).toFixed(0);
+        tgMsg.push(`${i + 1}. ${topMiners[i][0]}, ${count} <i>(${pct}%)</i>`);
+    }
+    tgMsg.push('');
+
+    const SATOSHIS_PER_XEC = 100;
+    const totalStakingRewardsXec = totalStakingRewardSats / SATOSHIS_PER_XEC;
+    const renderedTotalStakingRewards =
+        typeof xecPriceUsd !== 'undefined'
+            ? `$${(totalStakingRewardsXec * xecPriceUsd).toLocaleString(
+                  'en-US',
+                  {
+                      minimumFractionDigits: 0,
+                      maximumFractionDigits: 0,
+                  },
+              )}`
+            : `${totalStakingRewardsXec.toLocaleString('en-US', {
+                  minimumFractionDigits: 0,
+                  maximumFractionDigits: 0,
+              })} XEC`;
+
+    // Top stakers
+    const STAKERS_TO_SHOW = 3;
+    tgMsg.push(
+        `<b><i>${config.emojis.staker}${sortedStakerMap.size} stakers earned ${renderedTotalStakingRewards}</i></b>`,
+    );
+    tgMsg.push(`<u>Top ${STAKERS_TO_SHOW}</u>`);
+    const topStakers = [...sortedStakerMap.entries()].slice(0, STAKERS_TO_SHOW);
+    for (let i = 0; i < topStakers.length; i += 1) {
+        const staker = topStakers[i];
+        const count = staker[1].count;
+        const pct = (100 * (count / blockCount)).toFixed(0);
+        const addr = cashaddr.encodeOutputScript(staker[0]);
+        tgMsg.push(
+            `${i + 1}. ${`<a href="${
+                config.blockExplorer
+            }/address/${addr}">${returnAddressPreview(addr)}</a>`}, ${
+                staker[1].count
+            } <i>(${pct}%)</i>`,
+        );
+    }
+
+    // Tx breakdown
+
+    // Cashtab rewards
+    if (cashtabXecRewardCount > 0 || cashtabCachetRewardCount > 0) {
+        tgMsg.push('');
+        tgMsg.push(`<a href="https://cashtab.com/">Cashtab</a>`);
+        // Cashtab XEC rewards
+        if (cashtabXecRewardCount > 0) {
+            // 1 new user received 42 XEC
+            // or
+            // <n> new users received <...>
+            tgMsg.push(
+                `${
+                    config.emojis.gift
+                } <b>${cashtabXecRewardCount}</b> new user${
+                    cashtabXecRewardCount > 1 ? `s` : ''
+                } received <b>${satsToFormattedValue(
+                    cashtabXecRewardSats,
+                )}</b>`,
+            );
+        }
+        if (cashtabCachetRewardCount > 0) {
+            // 1 CACHET reward:
+            // or
+            // <n> CACHET rewards:
+            tgMsg.push(
+                `${
+                    config.emojis.tokenSend
+                } <b>${cashtabCachetRewardCount}</b> <a href="${
+                    config.blockExplorer
+                }/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">CACHET</a> reward${
+                    cashtabCachetRewardCount > 1 ? `s` : ''
+                }`,
+            );
+        }
+        tgMsg.push('');
+    }
+
+    // Agora partials
+    if (agoraTxs > 0) {
+        // Zero out counters for sorting purposes
+        agoraActions.forEach((agoraActionInfo, tokenId) => {
+            // Note we do not check adPrep as any token with adPrep has listing
+            const { buy, list, cancel } = agoraActionInfo;
+
+            if (typeof buy === 'undefined') {
+                agoraActionInfo.buy = { count: 0 };
+            }
+            if (typeof list === 'undefined') {
+                agoraActionInfo.list = { count: 0 };
+            }
+            if (typeof cancel === 'undefined') {
+                agoraActionInfo.cancel = { count: 0 };
+            }
+            agoraActions.set(tokenId, agoraActionInfo);
+        });
+
+        // Sort agoraActions by buys
+        const sortedAgoraActions = new Map(
+            [...agoraActions.entries()].sort(
+                (keyValueArrayA, keyValueArrayB) =>
+                    keyValueArrayB[1].buy.count - keyValueArrayA[1].buy.count,
+            ),
+        );
+
+        const agoraTokens = Array.from(sortedAgoraActions.keys());
+        const agoraTokenCount = agoraTokens.length;
+
+        tgMsg.push(
+            `${config.emojis.agora}${
+                config.emojis.token
+            } <b><i>${agoraTxs.toLocaleString('en-US')} Agora token tx${
+                agoraTxs > 1 ? 's' : ''
+            } from ${agoraTokenCount} token${
+                agoraTokenCount > 1 ? 's' : ''
+            }</i></b>`,
+        );
+
+        const AGORA_TOKENS_TO_SHOW = 10;
+
+        // Handle case where we do not see as many agora tokens as our max
+        const agoraTokensToShow =
+            agoraTokenCount < AGORA_TOKENS_TO_SHOW
+                ? agoraTokenCount
+                : AGORA_TOKENS_TO_SHOW;
+        const newsworthyAgoraTokens = agoraTokens.slice(0, agoraTokensToShow);
+
+        if (agoraTokenCount > AGORA_TOKENS_TO_SHOW) {
+            tgMsg.push(`<u>Top ${AGORA_TOKENS_TO_SHOW}</u>`);
+        }
+
+        // Emoji key
+        tgMsg.push(
+            `${config.emojis.agoraBuy}Buy, ${config.emojis.agoraList}List, ${config.emojis.agoraCancel}Cancel`,
+        );
+
+        for (let i = 0; i < newsworthyAgoraTokens.length; i += 1) {
+            const tokenId = newsworthyAgoraTokens[i];
+            const tokenActionInfo = sortedAgoraActions.get(tokenId);
+            const genesisInfo =
+                tokenInfoMap === false ? undefined : tokenInfoMap.get(tokenId);
+
+            const { buy, list, cancel } = tokenActionInfo;
+
+            tgMsg.push(
+                `<a href="${config.blockExplorer}/tx/${tokenId}">${
+                    typeof genesisInfo === 'undefined'
+                        ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
+                        : genesisInfo.tokenName
+                }</a>${
+                    typeof genesisInfo === 'undefined'
+                        ? ''
+                        : genesisInfo.tokenTicker !== ''
+                        ? ` (${genesisInfo.tokenTicker})`
+                        : ''
+                }: ${
+                    buy.count > 0
+                        ? `${config.emojis.agoraBuy}${
+                              buy.count > 1 ? `x${buy.count}` : ''
+                          }`
+                        : ''
+                }${
+                    list.count > 0
+                        ? `${config.emojis.agoraList}${
+                              list.count > 1 ? `x${list.count}` : ''
+                          }`
+                        : ''
+                }${
+                    cancel.count > 0
+                        ? `${config.emojis.agoraCancel}${
+                              cancel.count > 1 ? `x${cancel.count}` : ''
+                          }`
+                        : ''
+                }`,
+            );
+        }
+        // Newline after agora section
+        tgMsg.push('');
+    }
+    // Agora ONESHOT (NFTs)
+    if (agoraOneshotTxs > 0) {
+        // Zero out counters for sorting purposes
+        nftAgoraActions.forEach((agoraActionInfo, tokenId) => {
+            // Note we do not check adPrep as any token with adPrep has listing
+            const { buy, list, cancel } = agoraActionInfo;
+
+            if (typeof buy === 'undefined') {
+                agoraActionInfo.buy = { count: 0 };
+            }
+            if (typeof list === 'undefined') {
+                agoraActionInfo.list = { count: 0 };
+            }
+            if (typeof cancel === 'undefined') {
+                agoraActionInfo.cancel = { count: 0 };
+            }
+            nftAgoraActions.set(tokenId, agoraActionInfo);
+        });
+
+        // Sort agoraActions by buys
+        const sortedNftAgoraActions = new Map(
+            [...nftAgoraActions.entries()].sort(
+                (keyValueArrayA, keyValueArrayB) =>
+                    keyValueArrayB[1].buy.count - keyValueArrayA[1].buy.count,
+            ),
+        );
+
+        const agoraNftCollections = Array.from(sortedNftAgoraActions.keys());
+
+        const agoraCollectionCount = agoraNftCollections.length;
+        const agoraNftCount = uniqueAgoraNfts.size;
+
+        tgMsg.push(
+            `${config.emojis.agora}${
+                config.emojis.nft
+            } <b><i>${agoraOneshotTxs.toLocaleString('en-US')} Agora NFT tx${
+                agoraTxs > 1 ? 's' : ''
+            } from ${agoraNftCount} NFT${
+                agoraNftCount > 1 ? 's' : ''
+            } in ${agoraCollectionCount} collection${
+                agoraCollectionCount > 1 ? 's' : ''
+            }</i></b>`,
+        );
+
+        const AGORA_COLLECTIONS_TO_SHOW = 10;
+
+        // Handle case where we do not see as many agora tokens as our max
+        const agoraCollectionsToShow =
+            agoraCollectionCount < AGORA_COLLECTIONS_TO_SHOW
+                ? agoraCollectionCount
+                : AGORA_COLLECTIONS_TO_SHOW;
+        const newsworthyAgoraCollections = agoraNftCollections.slice(
+            0,
+            agoraCollectionsToShow,
+        );
+
+        if (agoraCollectionCount > AGORA_COLLECTIONS_TO_SHOW) {
+            tgMsg.push(`<u>Top ${AGORA_COLLECTIONS_TO_SHOW}</u>`);
+        }
+
+        // Repeat emoji key
+        tgMsg.push(
+            `${config.emojis.agoraBuy}Buy, ${config.emojis.agoraList}List, ${config.emojis.agoraCancel}Cancel`,
+        );
+
+        for (let i = 0; i < newsworthyAgoraCollections.length; i += 1) {
+            const tokenId = newsworthyAgoraCollections[i];
+            const tokenActionInfo = sortedNftAgoraActions.get(tokenId);
+            const genesisInfo =
+                tokenInfoMap === false ? undefined : tokenInfoMap.get(tokenId);
+
+            const { buy, list, cancel } = tokenActionInfo;
+
+            tgMsg.push(
+                `<a href="${config.blockExplorer}/tx/${tokenId}">${
+                    typeof genesisInfo === 'undefined'
+                        ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
+                        : genesisInfo.tokenName
+                }</a>${
+                    typeof genesisInfo === 'undefined'
+                        ? ''
+                        : genesisInfo.tokenTicker !== ''
+                        ? ` (${genesisInfo.tokenTicker})`
+                        : ''
+                }: ${
+                    buy.count > 0
+                        ? `${config.emojis.agoraBuy}${
+                              buy.count > 1 ? `x${buy.count}` : ''
+                          }`
+                        : ''
+                }${
+                    list.count > 0
+                        ? `${config.emojis.agoraList}${
+                              list.count > 1 ? `x${list.count}` : ''
+                          }`
+                        : ''
+                }${
+                    cancel.count > 0
+                        ? `${config.emojis.agoraCancel}${
+                              cancel.count > 1 ? `x${cancel.count}` : ''
+                          }`
+                        : ''
+                }`,
+            );
+        }
+        // Newline after agora section
+        tgMsg.push('');
+    }
+
+    // SLP 1 fungible summary
+    if (slpFungibleTxs > 0) {
+        // Sort tokenActions map by number of token actions
+        const sortedTokenActions = new Map(
+            [...tokenActions.entries()].sort(
+                (keyValueArrayA, keyValueArrayB) =>
+                    keyValueArrayB[1].actionCount -
+                    keyValueArrayA[1].actionCount,
+            ),
+        );
+
+        // nonAgoraTokens will probably include tokens with agora actions
+        // It's just that we want to present how many tokens had non-agora actions
+        const nonAgoraTokens = Array.from(sortedTokenActions.keys());
+
+        const nonAgoraTokenCount = nonAgoraTokens.length;
+        tgMsg.push(
+            `${config.emojis.token} <b><i>${slpFungibleTxs.toLocaleString(
+                'en-US',
+            )} token tx${
+                slpFungibleTxs > 1 ? 's' : ''
+            } from ${nonAgoraTokenCount} token${
+                nonAgoraTokenCount > 1 ? 's' : ''
+            }</i></b>`,
+        );
+
+        const NON_AGORA_TOKENS_TO_SHOW = 5;
+        const nonAgoraTokensToShow =
+            nonAgoraTokenCount < NON_AGORA_TOKENS_TO_SHOW
+                ? nonAgoraTokenCount
+                : NON_AGORA_TOKENS_TO_SHOW;
+        const newsworthyTokens = nonAgoraTokens.slice(0, nonAgoraTokensToShow);
+
+        if (nonAgoraTokenCount > NON_AGORA_TOKENS_TO_SHOW) {
+            tgMsg.push(`<u>Top ${NON_AGORA_TOKENS_TO_SHOW}</u>`);
+        }
+
+        for (let i = 0; i < newsworthyTokens.length; i += 1) {
+            const tokenId = newsworthyTokens[i];
+            const tokenActionInfo = sortedTokenActions.get(tokenId);
+            const genesisInfo =
+                tokenInfoMap === false ? undefined : tokenInfoMap.get(tokenId);
+
+            const { send, genesis, burn, mint } = tokenActionInfo;
+
+            tgMsg.push(
+                `<a href="${config.blockExplorer}/tx/${tokenId}">${
+                    typeof genesisInfo === 'undefined'
+                        ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
+                        : genesisInfo.tokenName
+                }</a>${
+                    typeof genesisInfo === 'undefined'
+                        ? ''
+                        : genesisInfo.tokenTicker !== ''
+                        ? ` (${genesisInfo.tokenTicker})`
+                        : ''
+                }: ${
+                    typeof genesis !== 'undefined'
+                        ? config.emojis.tokenGenesis
+                        : ''
+                }${
+                    typeof send !== 'undefined'
+                        ? `${config.emojis.arrowRight}${
+                              send.count > 1 ? `x${send.count}` : ''
+                          }`
+                        : ''
+                }${
+                    typeof burn !== 'undefined'
+                        ? `${config.emojis.tokenBurn}${
+                              burn.count > 1 ? `x${burn.count}` : ''
+                          }`
+                        : ''
+                }${
+                    typeof mint !== 'undefined'
+                        ? `${config.emojis.tokenMint}${
+                              mint.count > 1 ? `x${mint.count}` : ''
+                          }`
+                        : ''
+                }`,
+            );
+        }
+
+        // Line break for new section
+        tgMsg.push('');
+    }
+
+    // NFT summary
+    if (nftNonAgoraTokenEntries > 0) {
+        // Sort tokenActions map by number of token actions
+        const sortedNftActions = new Map(
+            [...nftActions.entries()].sort(
+                (keyValueArrayA, keyValueArrayB) =>
+                    keyValueArrayB[1].actionCount -
+                    keyValueArrayA[1].actionCount,
+            ),
+        );
+        const collectionsWithNonAgoraActions = Array.from(
+            sortedNftActions.keys(),
+        );
+        const collectionsWithNonAgoraActionsCount =
+            collectionsWithNonAgoraActions.length;
+
+        // Note that uniqueNonAgoraNfts and uniqueAgoraNfts can have some of the same members
+        // Some NFTs will have both agora actions and non-agora actions
+
+        tgMsg.push(
+            `${
+                config.emojis.nft
+            } <b><i>${nftNonAgoraTokenEntries.toLocaleString('en-US')} NFT tx${
+                nftNonAgoraTokenEntries > 1 ? 's' : ''
+            } from ${uniqueNonAgoraNfts.size} NFT${
+                uniqueNonAgoraNfts.size > 1 ? 's' : ''
+            } in ${collectionsWithNonAgoraActionsCount} collection${
+                collectionsWithNonAgoraActionsCount > 1 ? 's' : ''
+            }</i></b>`,
+        );
+
+        const NON_AGORA_COLLECTIONS_TO_SHOW = 5;
+        const nonAgoraCollectionsToShow =
+            collectionsWithNonAgoraActionsCount < NON_AGORA_COLLECTIONS_TO_SHOW
+                ? collectionsWithNonAgoraActionsCount
+                : NON_AGORA_COLLECTIONS_TO_SHOW;
+        const newsworthyCollections = collectionsWithNonAgoraActions.slice(
+            0,
+            nonAgoraCollectionsToShow,
+        );
+
+        if (
+            collectionsWithNonAgoraActionsCount > NON_AGORA_COLLECTIONS_TO_SHOW
+        ) {
+            tgMsg.push(`<u>Top ${NON_AGORA_COLLECTIONS_TO_SHOW}</u>`);
+        }
+
+        for (let i = 0; i < newsworthyCollections.length; i += 1) {
+            const tokenId = newsworthyCollections[i];
+            const tokenActionInfo = sortedNftActions.get(tokenId);
+            const genesisInfo =
+                tokenInfoMap === false ? undefined : tokenInfoMap.get(tokenId);
+
+            const { send, genesis, burn, mint } = tokenActionInfo;
+
+            tgMsg.push(
+                `<a href="${config.blockExplorer}/tx/${tokenId}">${
+                    typeof genesisInfo === 'undefined'
+                        ? `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`
+                        : genesisInfo.tokenName
+                }</a>${
+                    typeof genesisInfo === 'undefined'
+                        ? ''
+                        : genesisInfo.tokenTicker !== ''
+                        ? ` (${genesisInfo.tokenTicker})`
+                        : ''
+                }: ${
+                    typeof genesis !== 'undefined'
+                        ? `${config.emojis.tokenGenesis}${
+                              genesis.count > 1 ? `x${genesis.count}` : ''
+                          }`
+                        : ''
+                }${
+                    typeof send !== 'undefined'
+                        ? `${config.emojis.arrowRight}${
+                              send.count > 1 ? `x${send.count}` : ''
+                          }`
+                        : ''
+                }${
+                    typeof burn !== 'undefined'
+                        ? `${config.emojis.tokenBurn}${
+                              burn.count > 1 ? `x${burn.count}` : ''
+                          }`
+                        : ''
+                }${
+                    typeof mint !== 'undefined'
+                        ? `${config.emojis.tokenMint}${
+                              mint.count > 1 ? `x${mint.count}` : ''
+                          }`
+                        : ''
+                }`,
+            );
+        }
+        // Line break for new section
+        tgMsg.push('');
+    }
+
+    // Genesis and mints token summary
+    const unparsedTokenEntries =
+        alpTokenEntries > 0 ||
+        mintVaultTokenEntries > 0 ||
+        invalidTokenEntries > 0;
+    const hasTokenSummaryLines =
+        nftMints > 0 ||
+        newSlpTokensFixedSupply > 0 ||
+        newSlpTokensVariableSupply > 0 ||
+        unparsedTokenEntries;
+    if (nftMints > 0) {
+        tgMsg.push(
+            `${config.emojis.nft} <b><i>${nftMints} NFT mint${
+                nftMints > 1 ? 's' : ''
+            }</i></b>`,
+        );
+    }
+    if (newSlpTokensFixedSupply > 0) {
+        tgMsg.push(
+            `${
+                config.emojis.tokenFixed
+            } <b><i>${newSlpTokensFixedSupply} new fixed-supply token${
+                newSlpTokensFixedSupply > 1 ? 's' : ''
+            }</i></b>`,
+        );
+    }
+    if (newSlpTokensVariableSupply > 0) {
+        tgMsg.push(
+            `${
+                config.emojis.tokenMint
+            } <b><i>${newSlpTokensVariableSupply} new variable-supply token${
+                newSlpTokensVariableSupply > 1 ? 's' : ''
+            }</i></b>`,
+        );
+    }
+
+    // Unparsed token summary
+    if (alpTokenEntries > 0) {
+        tgMsg.push(
+            `${config.emojis.alp} <b><i>${alpTokenEntries.toLocaleString(
+                'en-US',
+            )} ALP tx${alpTokenEntries > 1 ? 's' : ''}</i></b>`,
+        );
+    }
+    if (mintVaultTokenEntries > 0) {
+        tgMsg.push(
+            `${
+                config.emojis.mintvault
+            } <b><i>${mintVaultTokenEntries.toLocaleString(
+                'en-US',
+            )} Mint Vault tx${mintVaultTokenEntries > 1 ? 's' : ''}</i></b>`,
+        );
+    }
+    if (invalidTokenEntries > 0) {
+        tgMsg.push(
+            `${
+                config.emojis.invalid
+            } <b><i>${invalidTokenEntries.toLocaleString(
+                'en-US',
+            )} invalid token tx${invalidTokenEntries > 1 ? 's' : ''}</i></b>`,
+        );
+    }
+    if (hasTokenSummaryLines) {
+        tgMsg.push('');
+    }
+    if (appTxs > 0) {
+        // Sort appTxMap by most common app txs
+        const sortedAppTxMap = new Map(
+            [...appTxMap.entries()].sort(
+                (keyValueArrayA, keyValueArrayB) =>
+                    keyValueArrayB[1] - keyValueArrayA[1],
+            ),
+        );
+        tgMsg.push(
+            `${config.emojis.app} <b><i>${appTxs.toLocaleString(
+                'en-US',
+            )} app tx${appTxs > 1 ? 's' : ''}</i></b>`,
+        );
+        sortedAppTxMap.forEach((count, lokadId) => {
+            // Do we recognize this app?
+            const supportedLokadApp = lokadMap.get(lokadId);
+            if (typeof supportedLokadApp === 'undefined') {
+                unknownLokadTxs += count;
+                // Go to the next lokadId
+                return;
+            }
+            const { name, emoji, url } = supportedLokadApp;
+            if (typeof url === 'undefined') {
+                tgMsg.push(
+                    `${emoji} <b>${count.toLocaleString('en-US')}</b> ${name}${
+                        count > 1 ? 's' : ''
+                    }`,
+                );
+            } else {
+                tgMsg.push(
+                    `${emoji} <b>${count.toLocaleString(
+                        'en-US',
+                    )}</b> <a href="${url}">${name}${count > 1 ? 's' : ''}</a>`,
+                );
+            }
+        });
+        // Add line for unknown txs
+        if (unknownLokadTxs > 0) {
+            tgMsg.push(
+                `${config.emojis.unknown} <b>${unknownLokadTxs.toLocaleString(
+                    'en-US',
+                )}</b> Unknown app tx${unknownLokadTxs > 1 ? 's' : ''}`,
+            );
+        }
+        tgMsg.push('');
+    }
+
+    if (binanceWithdrawalCount > 0) {
+        // Binance hot wallet
+        const binanceWithdrawalXec = binanceWithdrawalSats / SATOSHIS_PER_XEC;
+        const renderedBinanceWithdrawalSats =
+            typeof xecPriceUsd !== 'undefined'
+                ? `$${(binanceWithdrawalXec * xecPriceUsd).toLocaleString(
+                      'en-US',
+                      {
+                          minimumFractionDigits: 0,
+                          maximumFractionDigits: 0,
+                      },
+                  )}`
+                : `${binanceWithdrawalXec.toLocaleString('en-US', {
+                      minimumFractionDigits: 0,
+                      maximumFractionDigits: 0,
+                  })} XEC`;
+        tgMsg.push(`${config.emojis.bank} <b><i>Binance</i></b>`);
+        tgMsg.push(
+            `<b>${binanceWithdrawalCount}</b> withdrawal${
+                binanceWithdrawalCount > 1 ? 's' : ''
+            }, ${renderedBinanceWithdrawalSats}`,
+        );
+    }
+
+    return splitOverflowTgMsg(tgMsg);
+};
+
+/**
+ * Initialize action data for a token if not yet intialized
+ * Update action count if initialized
+ * @param tokenActionMap
+ * @param existingAction result from tokenActionMap.get(tokenId)
+ * @param tokenId
+ * @param action
+ */
+export const initializeOrIncrementTokenData = (
+    tokenActionMap: Map<string, TokenActions>,
+    existingActions: undefined | TokenActions,
+    tokenId: string,
+    action: TrackedTokenAction,
+) => {
+    tokenActionMap.set(
+        tokenId,
+        typeof existingActions === 'undefined'
+            ? {
+                  [action]: {
+                      count: 1,
+                  },
+                  actionCount: 1,
+              }
+            : {
+                  ...existingActions,
+                  [action]: {
+                      count:
+                          action in existingActions
+                              ? existingActions[action]!.count! + 1
+                              : 1,
+                  },
+                  actionCount: existingActions.actionCount + 1,
+              },
+    );
+};
diff --git a/apps/ecash-herald/src/telegram.js b/apps/ecash-herald/src/telegram.js
deleted file mode 100644
--- a/apps/ecash-herald/src/telegram.js
+++ /dev/null
@@ -1,150 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-const config = require('../config');
-// undocumented API behavior of HTML parsing mode, discovered through brute force
-const TG_MSG_MAX_LENGTH = 4096;
-
-module.exports = {
-    prepareStringForTelegramHTML: function (string) {
-        /*
-        See "HTML Style" at https://core.telegram.org/bots/api
-
-        Replace < with &lt;
-        Replace > with &gt;
-        Replace & with &amp;
-      */
-        let tgReadyString = string;
-        // need to replace the '&' characters first
-        tgReadyString = tgReadyString.replace(/&/g, '&amp;');
-        tgReadyString = tgReadyString.replace(/</g, '&lt;');
-        tgReadyString = tgReadyString.replace(/>/g, '&gt;');
-
-        return tgReadyString;
-    },
-    splitOverflowTgMsg: function (tgMsgArray) {
-        /* splitOverflowTgMsg
-         *
-         * Params
-         * tgMsgArray - an array of unjoined strings prepared by getBlockTgMessage
-         *              each string has length <= 4096 characters
-         *
-         * Output
-         * tgMsgStrings - an array of ready-to-broadcast HTML-parsed telegram messages, all under
-         *                the 4096 character limit
-         */
-
-        // Iterate over tgMsgArray to build an array of messages under the TG_MSG_MAX_LENGTH ceiling
-        const tgMsgStrings = [];
-
-        let thisTgMsgStringLength = 0;
-        let sliceStartIndex = 0;
-        for (let i = 0; i < tgMsgArray.length; i += 1) {
-            const thisLine = tgMsgArray[i];
-            // Account for the .join('\n'), each line has an extra 2 characters
-            // Note: this is undocumented behavior of telegram API HTML parsing mode
-            // '\n' is counted as 2 characters and also is parsed as a new line in HTML mode
-            thisTgMsgStringLength += thisLine.length + 2;
-            console.assert(thisLine.length + 2 <= TG_MSG_MAX_LENGTH, '%o', {
-                length: thisLine.length + 2,
-                line: thisLine,
-                error: 'Telegram message line is longer than 4096 characters',
-            });
-
-            // If this particular message line pushes the message over TG_MSG_MAX_LENGTH
-            // less 2 as there is no `\n` at the end of the last line of the msg
-            if (thisTgMsgStringLength - 2 > TG_MSG_MAX_LENGTH) {
-                // Build a msg string with preceding lines, i.e. do not include this i'th line
-                const sliceEndIndex = i; // Note that the slice end index is not included
-                tgMsgStrings.push(
-                    tgMsgArray.slice(sliceStartIndex, sliceEndIndex).join('\n'),
-                );
-                // Reset sliceStartIndex and thisTgMsgStringLength for the next message
-                sliceStartIndex = sliceEndIndex;
-
-                // Reset thisTgMsgStringLength to thisLine.length + 2;
-                // The line of the current index will go into the next batched slice
-                thisTgMsgStringLength = thisLine.length + 2;
-            }
-        }
-
-        // Build a tg msg of all unused lines, if you have them
-        if (sliceStartIndex < tgMsgArray.length) {
-            tgMsgStrings.push(tgMsgArray.slice(sliceStartIndex).join('\n'));
-        }
-
-        return tgMsgStrings;
-    },
-    sendBlockSummary: async function (
-        tgMsgStrings,
-        telegramBot,
-        channelId,
-        blockheightOrMsgDesc = false,
-    ) {
-        /* sendBlockSummary
-         *
-         * Params
-         * tgMsgStrings - an array of ready-to-be broadcast HTML-parsed telegram messages,
-         * all under the 4096 character length limit
-         * telegramBot - a telegram bot instance
-         * channelId - the channel where the messages will be broadcast
-         *
-         * Output
-         * Message(s) will be broadcast by telegramBot to channelId
-         * If there are multiple messages, each message will be sent as a reply to its
-         * preceding message
-         * Function returns 'false' if there is an error in sending any one message
-         * Function returns an array of msgSuccess objects for each successfully send msg
-         */
-
-        let msgReplyId = false;
-        let msgSuccessArray = [];
-        for (let i = 0; i < tgMsgStrings.length; i += 1) {
-            const thisMsg = tgMsgStrings[i];
-            let msgSuccess;
-            const thisMsgOptions = msgReplyId
-                ? {
-                      ...config.tgMsgOptions,
-                      reply_to_message_id: msgReplyId,
-                  }
-                : config.tgMsgOptions;
-            try {
-                msgSuccess = await telegramBot.sendMessage(
-                    channelId,
-                    thisMsg,
-                    thisMsgOptions,
-                );
-                msgReplyId = msgSuccess.message_id;
-                msgSuccessArray.push(msgSuccess);
-            } catch (err) {
-                console.log(
-                    `Error in sending msg in sendBlockSummary, telegramBot.send(${thisMsg}) for msg ${
-                        i + 1
-                    } of ${tgMsgStrings.length}`,
-                    err,
-                );
-                return false;
-            }
-        }
-        if (msgSuccessArray.length === tgMsgStrings.length) {
-            if (typeof blockheightOrMsgDesc === 'number') {
-                console.log('\x1b[32m%s\x1b[0m', `✔ ${blockheightOrMsgDesc}`);
-            } else if (blockheightOrMsgDesc === 'daily') {
-                console.log(
-                    '\x1b[32m%s\x1b[0m',
-                    `✔ Sent daily summary of last 24 hrs`,
-                );
-            }
-            return msgSuccessArray;
-        }
-        // Catch potential edge case
-        console.log({
-            msgsSent: msgSuccessArray.length,
-            msgsAttempted: tgMsgStrings.length,
-            error: 'Failed to send all messages',
-        });
-        return false;
-    },
-};
diff --git a/apps/ecash-herald/src/telegram.ts b/apps/ecash-herald/src/telegram.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/src/telegram.ts
@@ -0,0 +1,155 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import config from '../config';
+import TelegramBot, {
+    Message,
+    SendMessageOptions,
+} from 'node-telegram-bot-api';
+import { MockTelegramBot } from '../test/mocks/telegramBotMock';
+import { SendMessageResponse } from './events';
+// undocumented API behavior of HTML parsing mode, discovered through brute force
+const TG_MSG_MAX_LENGTH = 4096;
+
+export const prepareStringForTelegramHTML = (string: string): string => {
+    /*
+        See "HTML Style" at https://core.telegram.org/bots/api
+
+        Replace < with &lt;
+        Replace > with &gt;
+        Replace & with &amp;
+      */
+    let tgReadyString = string;
+    // need to replace the '&' characters first
+    tgReadyString = tgReadyString.replace(/&/g, '&amp;');
+    tgReadyString = tgReadyString.replace(/</g, '&lt;');
+    tgReadyString = tgReadyString.replace(/>/g, '&gt;');
+
+    return tgReadyString;
+};
+export const splitOverflowTgMsg = (tgMsgArray: string[]): string[] => {
+    /* splitOverflowTgMsg
+     *
+     * Params
+     * tgMsgArray - an array of unjoined strings prepared by getBlockTgMessage
+     *              each string has length <= 4096 characters
+     *
+     * Output
+     * tgMsgStrings - an array of ready-to-broadcast HTML-parsed telegram messages, all under
+     *                the 4096 character limit
+     */
+
+    // Iterate over tgMsgArray to build an array of messages under the TG_MSG_MAX_LENGTH ceiling
+    const tgMsgStrings = [];
+
+    let thisTgMsgStringLength = 0;
+    let sliceStartIndex = 0;
+    for (let i = 0; i < tgMsgArray.length; i += 1) {
+        const thisLine = tgMsgArray[i];
+        // Account for the .join('\n'), each line has an extra 2 characters
+        // Note: this is undocumented behavior of telegram API HTML parsing mode
+        // '\n' is counted as 2 characters and also is parsed as a new line in HTML mode
+        thisTgMsgStringLength += thisLine.length + 2;
+        console.assert(thisLine.length + 2 <= TG_MSG_MAX_LENGTH, '%o', {
+            length: thisLine.length + 2,
+            line: thisLine,
+            error: 'Telegram message line is longer than 4096 characters',
+        });
+
+        // If this particular message line pushes the message over TG_MSG_MAX_LENGTH
+        // less 2 as there is no `\n` at the end of the last line of the msg
+        if (thisTgMsgStringLength - 2 > TG_MSG_MAX_LENGTH) {
+            // Build a msg string with preceding lines, i.e. do not include this i'th line
+            const sliceEndIndex = i; // Note that the slice end index is not included
+            tgMsgStrings.push(
+                tgMsgArray.slice(sliceStartIndex, sliceEndIndex).join('\n'),
+            );
+            // Reset sliceStartIndex and thisTgMsgStringLength for the next message
+            sliceStartIndex = sliceEndIndex;
+
+            // Reset thisTgMsgStringLength to thisLine.length + 2;
+            // The line of the current index will go into the next batched slice
+            thisTgMsgStringLength = thisLine.length + 2;
+        }
+    }
+
+    // Build a tg msg of all unused lines, if you have them
+    if (sliceStartIndex < tgMsgArray.length) {
+        tgMsgStrings.push(tgMsgArray.slice(sliceStartIndex).join('\n'));
+    }
+
+    return tgMsgStrings;
+};
+
+export const sendBlockSummary = async (
+    tgMsgStrings: string[],
+    telegramBot: TelegramBot | MockTelegramBot,
+    channelId: string,
+    blockheightOrMsgDesc?: number | string,
+) => {
+    /* sendBlockSummary
+     *
+     * Params
+     * tgMsgStrings - an array of ready-to-be broadcast HTML-parsed telegram messages,
+     * all under the 4096 character length limit
+     * telegramBot - a telegram bot instance
+     * channelId - the channel where the messages will be broadcast
+     *
+     * Output
+     * Message(s) will be broadcast by telegramBot to channelId
+     * If there are multiple messages, each message will be sent as a reply to its
+     * preceding message
+     * Function returns 'false' if there is an error in sending any one message
+     * Function returns an array of msgSuccess objects for each successfully send msg
+     */
+
+    let msgReplyId;
+    let msgSuccessArray = [];
+    for (let i = 0; i < tgMsgStrings.length; i += 1) {
+        const thisMsg = tgMsgStrings[i];
+        let msgSuccess: Message | SendMessageResponse;
+        const thisMsgOptions: SendMessageOptions =
+            typeof msgReplyId === 'number'
+                ? {
+                      ...config.tgMsgOptions,
+                      reply_to_message_id: msgReplyId,
+                  }
+                : config.tgMsgOptions;
+        try {
+            msgSuccess = (await telegramBot.sendMessage(
+                channelId,
+                thisMsg,
+                thisMsgOptions,
+            )) as SendMessageResponse;
+            msgReplyId = msgSuccess.message_id;
+            msgSuccessArray.push(msgSuccess);
+        } catch (err) {
+            console.log(
+                `Error in sending msg in sendBlockSummary, telegramBot.send(${thisMsg}) for msg ${
+                    i + 1
+                } of ${tgMsgStrings.length}`,
+                err,
+            );
+            return false;
+        }
+    }
+    if (msgSuccessArray.length === tgMsgStrings.length) {
+        if (typeof blockheightOrMsgDesc === 'number') {
+            console.log('\x1b[32m%s\x1b[0m', `✔ ${blockheightOrMsgDesc}`);
+        } else if (blockheightOrMsgDesc === 'daily') {
+            console.log(
+                '\x1b[32m%s\x1b[0m',
+                `✔ Sent daily summary of last 24 hrs`,
+            );
+        }
+        return msgSuccessArray;
+    }
+    // Catch potential edge case
+    console.log({
+        msgsSent: msgSuccessArray.length,
+        msgsAttempted: tgMsgStrings.length,
+        error: 'Failed to send all messages',
+    });
+    return false;
+};
diff --git a/apps/ecash-herald/src/utils.js b/apps/ecash-herald/src/utils.js
deleted file mode 100644
--- a/apps/ecash-herald/src/utils.js
+++ /dev/null
@@ -1,441 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-const axios = require('axios');
-const config = require('../config');
-const BigNumber = require('bignumber.js');
-const addressDirectory = require('../constants/addresses');
-const { consume } = require('ecash-script');
-
-module.exports = {
-    returnAddressPreview: function (cashAddress, sliceSize = 3) {
-        // Check known addresses for a tag
-        if (addressDirectory.has(cashAddress)) {
-            return addressDirectory.get(cashAddress).tag;
-        }
-        const addressParts = cashAddress.split(':');
-        const unprefixedAddress = addressParts[addressParts.length - 1];
-        return `${unprefixedAddress.slice(
-            0,
-            sliceSize,
-        )}...${unprefixedAddress.slice(-sliceSize)}`;
-    },
-    /**
-     * Get the price API url herald would use for specified config
-     * @param {object} config ecash-herald config object
-     * @returns {string} expected URL of price API call
-     */
-    getCoingeckoApiUrl: function (config) {
-        return `${config.priceApi.apiBase}?ids=${config.priceApi.cryptos
-            .map(crypto => crypto.coingeckoSlug)
-            .join(',')}&vs_currencies=${
-            config.priceApi.fiat
-        }&precision=${config.priceApi.precision.toString()}`;
-    },
-    getCoingeckoPrices: async function (priceInfoObj) {
-        const { apiBase, cryptos, fiat, precision } = priceInfoObj;
-        let coingeckoSlugs = cryptos.map(crypto => crypto.coingeckoSlug);
-        let apiUrl = `${apiBase}?ids=${coingeckoSlugs.join(
-            ',',
-        )}&vs_currencies=${fiat}&precision=${precision.toString()}`;
-        // https://api.coingecko.com/api/v3/simple/price?ids=ecash,bitcoin,ethereum&vs_currencies=usd&precision=8
-        let coingeckoApiResponse;
-        try {
-            coingeckoApiResponse = await axios.get(apiUrl);
-            const { data } = coingeckoApiResponse;
-            // Validate for expected shape
-            // For each key in `cryptoIds`, data must contain {<fiat>: <price>}
-            let coingeckoPriceArray = [];
-            if (data && typeof data === 'object') {
-                for (let i = 0; i < coingeckoSlugs.length; i += 1) {
-                    const thisCoingeckoSlug = coingeckoSlugs[i];
-                    if (
-                        !data[thisCoingeckoSlug] ||
-                        !data[thisCoingeckoSlug][fiat]
-                    ) {
-                        return false;
-                    }
-                    // Create more useful output format
-                    const thisPriceInfo = {
-                        fiat,
-                        price: data[thisCoingeckoSlug][fiat],
-                        ticker: cryptos.filter(
-                            el => el.coingeckoSlug === thisCoingeckoSlug,
-                        )[0].ticker,
-                    };
-                    if (thisPriceInfo.ticker === 'XEC') {
-                        coingeckoPriceArray.unshift(thisPriceInfo);
-                    } else {
-                        coingeckoPriceArray.push(thisPriceInfo);
-                    }
-                }
-                return {
-                    coingeckoResponse: data,
-                    coingeckoPrices: coingeckoPriceArray,
-                };
-            }
-            return false;
-        } catch (err) {
-            console.log(
-                `Error fetching prices of ${coingeckoSlugs.join(
-                    ',',
-                )} from ${apiUrl}`,
-                err,
-            );
-        }
-        return false;
-    },
-    formatPrice: function (price, fiatCode) {
-        // Get symbol
-        let fiatSymbol = config.fiatReference[fiatCode];
-
-        // If you can't find the symbol, don't show one
-        if (typeof fiatSymbol === 'undefined') {
-            fiatSymbol = '';
-        }
-
-        // No decimal points for prices greater than 100
-        if (price > 100) {
-            return `${fiatSymbol}${price.toLocaleString('en-US', {
-                maximumFractionDigits: 0,
-            })}`;
-        }
-        // 2 decimal places for prices between 1 and 100
-        if (price > 1) {
-            return `${fiatSymbol}${price.toLocaleString('en-US', {
-                maximumFractionDigits: 2,
-            })}`;
-        }
-        // All decimal places for lower prices
-        // For now, these will only be XEC prices
-        return `${fiatSymbol}${price.toLocaleString('en-US', {
-            maximumFractionDigits: 8,
-        })}`;
-    },
-    /**
-     * Return a formatted string for a telegram msg given an amount of satoshis     *
-     * @param {number} xecAmount amount of XEC as a number
-     * @returns {string}
-     */
-    formatXecAmount: function (xecAmount) {
-        // Initialize displayed string variables
-        let displayedAmount, descriptor;
-
-        // Initialize displayedDecimals as 0
-        let displayedDecimals = 0;
-
-        // Build format string for fixed levels
-        if (xecAmount < 10) {
-            // If xecAmount is less than 10, return un-rounded
-            displayedAmount = xecAmount;
-            descriptor = '';
-            displayedDecimals = 2;
-        } else if (xecAmount < 1000) {
-            displayedAmount = xecAmount;
-            descriptor = '';
-            // If xecAmount is between 10 and 1k, return rounded
-        } else if (xecAmount < 1000000) {
-            // If xecAmount is between 1k and 1 million, return formatted + rounded
-            displayedAmount = xecAmount / 1000; // thousands
-            descriptor = 'k';
-        } else if (xecAmount < 1000000000) {
-            // If xecAmount is between 1 million and 1 billion, return formatted + rounded
-            displayedAmount = xecAmount / 1000000; // millions
-            descriptor = 'M';
-        } else if (xecAmount < 1000000000000) {
-            // If xecAmount is between 1 billion and 1 trillion, return formatted + rounded
-            displayedAmount = xecAmount / 1000000000; // billions
-            descriptor = 'B';
-        } else if (xecAmount >= 1000000000000) {
-            // If xecAmount is greater than 1 trillion, return formatted + rounded
-            displayedAmount = xecAmount / 1000000000000;
-            descriptor = 'T';
-        }
-
-        return `${displayedAmount.toLocaleString('en-US', {
-            maximumFractionDigits: displayedDecimals,
-        })}${descriptor} XEC`;
-    },
-    /**
-     * Return a formatted string of fiat if price info is available and > $1
-     * Otherwise return formatted XEC amount
-     * @param {integer} satoshis
-     * @param {array or false} coingeckoPrices [{fiat, price}...{fiat, price}] with xec price at index 0
-     */
-    satsToFormattedValue: function (satoshis, coingeckoPrices) {
-        // Get XEC qty
-        const xecAmount = satoshis / 100;
-
-        if (!coingeckoPrices) {
-            return module.exports.formatXecAmount(xecAmount);
-        }
-        // Get XEC price from index 0
-        const { fiat, price } = coingeckoPrices[0];
-
-        // Get fiat price
-        let fiatAmount = xecAmount * price;
-        const fiatSymbol = config.fiatReference[fiat];
-
-        // Format fiatAmount for different tiers
-        let displayedAmount;
-        let localeOptions = { maximumFractionDigits: 0 };
-        let descriptor = '';
-
-        if (fiatAmount === 0) {
-            // Txs that send nothing, e.g. a one-input tx of 5.46 XEC, should keep defaults above
-        } else if (fiatAmount < 0.01) {
-            // enough decimal places to show one significant digit
-            localeOptions = {
-                minimumFractionDigits: -Math.floor(Math.log10(fiatAmount)),
-            };
-        } else if (fiatAmount < 1) {
-            // TODO two decimal places
-            localeOptions = { minimumFractionDigits: 2 };
-        }
-
-        if (fiatAmount < 1000) {
-            displayedAmount = fiatAmount;
-            descriptor = '';
-        } else if (fiatAmount < 1000000) {
-            // thousands
-            displayedAmount = fiatAmount / 1000;
-            descriptor = 'k';
-        } else if (fiatAmount < 1000000000) {
-            // millions
-            displayedAmount = fiatAmount / 1000000;
-            descriptor = 'M';
-        } else if (fiatAmount >= 1000000000) {
-            // billions or more
-            displayedAmount = fiatAmount / 1000000000;
-            descriptor = 'B';
-        }
-
-        return `${fiatSymbol}${displayedAmount.toLocaleString(
-            'en-US',
-            localeOptions,
-        )}${descriptor}`;
-    },
-    jsonReplacer: function (key, value) {
-        if (value instanceof Map) {
-            const keyValueArray = Array.from(value.entries());
-
-            for (let i = 0; i < keyValueArray.length; i += 1) {
-                const thisKeyValue = keyValueArray[i]; // [key, value]
-                // If this is not an empty map
-                if (typeof thisKeyValue !== 'undefined') {
-                    // Note: this value is an array of length 2
-                    // [key, value]
-                    // Check if value is a big number
-                    if (thisKeyValue[1] instanceof BigNumber) {
-                        // Replace it
-                        thisKeyValue[1] = {
-                            // Note, if you use dataType: 'BigNumber', it will not work
-                            // This must be reserved
-                            // Use a term that is definitely not reserved but also recognizable as
-                            // "the dev means BigNumber here"
-                            dataType: 'BigNumberReplacer',
-                            value: thisKeyValue[1].toString(),
-                        };
-                    }
-                }
-            }
-
-            return {
-                dataType: 'Map',
-                value: keyValueArray,
-            };
-        } else if (value instanceof Set) {
-            return {
-                dataType: 'Set',
-                value: Array.from(value.keys()),
-            };
-        } else {
-            return value;
-        }
-    },
-    jsonReviver: function (key, value) {
-        if (typeof value === 'object' && value !== null) {
-            if (value.dataType === 'Map') {
-                // If the map is not empty
-                if (typeof value.value[0] !== 'undefined') {
-                    /* value.value is an array of keyValue arrays
-                     * e.g.
-                     * [
-                     *  [key1, value1],
-                     *  [key2, value2],
-                     *  [key3, value3],
-                     * ]
-                     */
-                    // Iterate over each keyValue of the map
-                    for (let i = 0; i < value.value.length; i += 1) {
-                        const thisKeyValuePair = value.value[i]; // [key, value]
-                        let thisValue = thisKeyValuePair[1];
-                        if (
-                            thisValue &&
-                            thisValue.dataType === 'BigNumberReplacer'
-                        ) {
-                            // If this is saved BigNumber, replace it with an actual BigNumber
-                            // note, you can't use thisValue = new BigNumber(thisValue.value)
-                            // Need to use this specific array entry
-                            value.value[i][1] = new BigNumber(
-                                value.value[i][1].value,
-                            );
-                        }
-                    }
-                }
-                return new Map(value.value);
-            }
-            if (value.dataType === 'Set') {
-                return new Set(value.value);
-            }
-        }
-        return value;
-    },
-    /**
-     * Convert a map to a key value array
-     * Useful to generate test vectors by `console.log(mapToKeyValueArray(someMap))` in a function
-     * @param {map} map
-     * @returns array
-     */
-    mapToKeyValueArray: function (map) {
-        let kvArray = [];
-        map.forEach((value, key) => {
-            kvArray.push([key, value]);
-        });
-        return kvArray;
-    },
-    /**
-     * Assign appropriate emoji based on a balance in satoshis
-     * @param {integer} balanceSats
-     * @returns {string} emoji determined by thresholds set in config
-     */
-    getEmojiFromBalanceSats: function (balanceSats) {
-        const { whaleSats, emojis } = config;
-        if (balanceSats >= whaleSats.bigWhale) {
-            return emojis.bigWhale;
-        }
-        if (balanceSats >= whaleSats.modestWhale) {
-            return emojis.modestWhale;
-        }
-        if (balanceSats >= whaleSats.shark) {
-            return emojis.shark;
-        }
-        if (balanceSats >= whaleSats.swordfish) {
-            return emojis.swordfish;
-        }
-        if (balanceSats >= whaleSats.barracuda) {
-            return emojis.barracuda;
-        }
-        if (balanceSats >= whaleSats.octopus) {
-            return emojis.octopus;
-        }
-        if (balanceSats >= whaleSats.piranha) {
-            return emojis.piranha;
-        }
-        if (balanceSats >= whaleSats.crab) {
-            return emojis.crab;
-        }
-        return emojis.shrimp;
-    },
-    /**
-     * Convert an integer-stored number with known decimals into a formatted decimal string
-     * Useful for converting token send quantities to a human-readable string
-     * @param {string} bnString an integer value as a string, e.g 100000012
-     * @param {number} decimals the number of expected decimal places, e.g. 2
-     * @returns {string} e.g. 1,000,000.12
-     */
-    bigNumberAmountToLocaleString: function (bnString, decimals) {
-        const totalLength = bnString.length;
-
-        // Get the values that come after the decimal place
-        const decimalValues =
-            decimals === 0 ? '' : bnString.slice(-1 * decimals);
-        const decimalLength = decimalValues.length;
-
-        // Get the values that come before the decimal place
-        const intValue = bnString.slice(0, totalLength - decimalLength);
-
-        // Use toLocaleString() to format the amount before the decimal place with commas
-        return `${BigInt(intValue).toLocaleString('en-US', {
-            maximumFractionDigits: 0,
-        })}${decimals !== 0 ? `.${decimalValues}` : ''}`;
-    },
-    /**
-     * Determine if an OP_RETURN's hex values include characters outside of printable ASCII range
-     * @param {string} hexString hex string containing an even number of characters
-     */
-    containsOnlyPrintableAscii: function (hexString) {
-        if (hexString.length % 2 !== 0) {
-            // If hexString has an odd number of characters, it is certainly not ascii
-            return false;
-        }
-
-        // Values lower than 32 are control characters (127 also control char)
-        // We could tolerate LF and CR which are in this range, but they make
-        // the msg awkward in Telegram -- so they are left out
-        const MIN_ASCII_PRINTABLE_DECIMAL = 32;
-        const MAX_ASCII_PRINTABLE_DECIMAL = 126;
-        const stack = { remainingHex: hexString };
-
-        while (stack.remainingHex.length > 0) {
-            const thisByte = parseInt(consume(stack, 1), 16);
-
-            if (
-                thisByte > MAX_ASCII_PRINTABLE_DECIMAL ||
-                thisByte < MIN_ASCII_PRINTABLE_DECIMAL
-            ) {
-                return false;
-            }
-        }
-        return true;
-    },
-    /**
-     * Get the expected next staking reward winner and store it in the memory
-     * cache if the returned value targets the expected next block height.
-     * @param {number} nextBlockHeight The next block height
-     * @param {object} memoryCache The cache to store the result
-     */
-    getNextStakingReward: async function (nextBlockHeight, memoryCache) {
-        let retries = 10;
-
-        while (retries > 0) {
-            try {
-                const nextStakingReward = (
-                    await axios.get(config.stakingRewardApiUrl)
-                ).data;
-
-                if (nextStakingReward.nextBlockHeight === nextBlockHeight) {
-                    const { address, scriptHex } = nextStakingReward;
-
-                    const cachedObject = {
-                        scriptHex: scriptHex,
-                    };
-
-                    // Note: address can be undefined
-                    if (typeof address !== 'undefined') {
-                        cachedObject.address = address;
-                    }
-
-                    memoryCache.set(`${nextBlockHeight}`, cachedObject);
-
-                    return true;
-                }
-            } catch (err) {
-                // Fallthrough
-            }
-
-            retries -= 1;
-
-            // Wait for 2 seconds before retrying
-            await new Promise(resolve => setTimeout(resolve, 2000));
-        }
-
-        console.log(
-            `Failed to fetch the expected staking reward for block ${nextBlockHeight}`,
-        );
-
-        return false;
-    },
-};
diff --git a/apps/ecash-herald/src/utils.ts b/apps/ecash-herald/src/utils.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/src/utils.ts
@@ -0,0 +1,479 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import axios from 'axios';
+import config, { HeraldConfig, HeraldPriceApi, FiatCode } from '../config';
+import BigNumber from 'bignumber.js';
+import addressDirectory from '../constants/addresses';
+import { consume } from 'ecash-script';
+import { MemoryCache } from 'cache-manager';
+
+export const returnAddressPreview = (
+    cashAddress: string,
+    sliceSize = 3,
+): string => {
+    // Check known addresses for a tag
+    const addrInfo = addressDirectory.get(cashAddress);
+    if (typeof addrInfo?.tag !== 'undefined') {
+        return addrInfo.tag;
+    }
+    const addressParts = cashAddress.split(':');
+    const unprefixedAddress = addressParts[addressParts.length - 1];
+    return `${unprefixedAddress.slice(
+        0,
+        sliceSize,
+    )}...${unprefixedAddress.slice(-sliceSize)}`;
+};
+
+/**
+ * Get the price API url herald would use for specified config
+ * @param config ecash-herald config object
+ * @returns expected URL of price API call
+ */
+export const getCoingeckoApiUrl = (config: HeraldConfig): string => {
+    return `${config.priceApi.apiBase}?ids=${config.priceApi.cryptos
+        .map(crypto => crypto.coingeckoSlug)
+        .join(',')}&vs_currencies=${
+        config.priceApi.fiat
+    }&precision=${config.priceApi.precision.toString()}`;
+};
+
+// CoinGeckoResponse: any
+export interface CoinGeckoPrice {
+    fiat: FiatCode;
+    price: number;
+    ticker: string;
+}
+interface GetCoingeckPricesResponse {
+    coingeckoResponse: any;
+    coingeckoPrices: CoinGeckoPrice[];
+}
+export const getCoingeckoPrices = async (
+    priceInfoObj: HeraldPriceApi,
+): Promise<false | GetCoingeckPricesResponse> => {
+    const { apiBase, cryptos, fiat, precision } = priceInfoObj;
+    let coingeckoSlugs = cryptos.map(crypto => crypto.coingeckoSlug);
+    let apiUrl = `${apiBase}?ids=${coingeckoSlugs.join(
+        ',',
+    )}&vs_currencies=${fiat}&precision=${precision.toString()}`;
+    // https://api.coingecko.com/api/v3/simple/price?ids=ecash,bitcoin,ethereum&vs_currencies=usd&precision=8
+    let coingeckoApiResponse;
+    try {
+        coingeckoApiResponse = await axios.get(apiUrl);
+        const { data } = coingeckoApiResponse;
+        // Validate for expected shape
+        // For each key in `cryptoIds`, data must contain {<fiat>: <price>}
+        let coingeckoPriceArray = [];
+        if (data && typeof data === 'object') {
+            for (let i = 0; i < coingeckoSlugs.length; i += 1) {
+                const thisCoingeckoSlug = coingeckoSlugs[i];
+                if (
+                    !data[thisCoingeckoSlug] ||
+                    !data[thisCoingeckoSlug][fiat]
+                ) {
+                    return false;
+                }
+                // Create more useful output format
+                const thisPriceInfo = {
+                    fiat,
+                    price: data[thisCoingeckoSlug][fiat],
+                    ticker: cryptos.filter(
+                        el => el.coingeckoSlug === thisCoingeckoSlug,
+                    )[0].ticker,
+                };
+                if (thisPriceInfo.ticker === 'XEC') {
+                    coingeckoPriceArray.unshift(thisPriceInfo);
+                } else {
+                    coingeckoPriceArray.push(thisPriceInfo);
+                }
+            }
+            return {
+                coingeckoResponse: data,
+                coingeckoPrices: coingeckoPriceArray,
+            };
+        }
+        return false;
+    } catch (err) {
+        console.log(
+            `Error fetching prices of ${coingeckoSlugs.join(
+                ',',
+            )} from ${apiUrl}`,
+            err,
+        );
+    }
+    return false;
+};
+
+export const formatPrice = (price: number, fiatCode: FiatCode): string => {
+    // Get symbol
+    let fiatSymbol = config.fiatReference[fiatCode];
+
+    // If you can't find the symbol, don't show one
+    if (typeof fiatSymbol === 'undefined') {
+        fiatSymbol = '';
+    }
+
+    // No decimal points for prices greater than 100
+    if (price > 100) {
+        return `${fiatSymbol}${price.toLocaleString('en-US', {
+            maximumFractionDigits: 0,
+        })}`;
+    }
+    // 2 decimal places for prices between 1 and 100
+    if (price > 1) {
+        return `${fiatSymbol}${price.toLocaleString('en-US', {
+            maximumFractionDigits: 2,
+        })}`;
+    }
+    // All decimal places for lower prices
+    // For now, these will only be XEC prices
+    return `${fiatSymbol}${price.toLocaleString('en-US', {
+        maximumFractionDigits: 8,
+    })}`;
+};
+
+/**
+ * Return a formatted string for a telegram msg given an amount of satoshis     *
+ * @param xecAmount amount of XEC as a number
+ */
+export const formatXecAmount = (xecAmount: number): string => {
+    // Initialize displayed string variables
+    let displayedAmount, descriptor;
+
+    // Initialize displayedDecimals as 0
+    let displayedDecimals = 0;
+
+    // Build format string for fixed levels
+    if (xecAmount < 10) {
+        // If xecAmount is less than 10, return un-rounded
+        displayedAmount = xecAmount;
+        descriptor = '';
+        displayedDecimals = 2;
+    } else if (xecAmount < 1000) {
+        displayedAmount = xecAmount;
+        descriptor = '';
+        // If xecAmount is between 10 and 1k, return rounded
+    } else if (xecAmount < 1000000) {
+        // If xecAmount is between 1k and 1 million, return formatted + rounded
+        displayedAmount = xecAmount / 1000; // thousands
+        descriptor = 'k';
+    } else if (xecAmount < 1000000000) {
+        // If xecAmount is between 1 million and 1 billion, return formatted + rounded
+        displayedAmount = xecAmount / 1000000; // millions
+        descriptor = 'M';
+    } else if (xecAmount < 1000000000000) {
+        // If xecAmount is between 1 billion and 1 trillion, return formatted + rounded
+        displayedAmount = xecAmount / 1000000000; // billions
+        descriptor = 'B';
+    } else if (xecAmount >= 1000000000000) {
+        // If xecAmount is greater than 1 trillion, return formatted + rounded
+        displayedAmount = xecAmount / 1000000000000;
+        descriptor = 'T';
+    }
+
+    return `${displayedAmount!.toLocaleString('en-US', {
+        maximumFractionDigits: displayedDecimals,
+    })}${descriptor} XEC`;
+};
+
+/**
+ * Return a formatted string of fiat if price info is available and > $1
+ * Otherwise return formatted XEC amount
+ * @param {integer} satoshis
+ * @param {array or false} coingeckoPrices [{fiat, price}...{fiat, price}] with xec price at index 0
+ */
+export const satsToFormattedValue = (
+    satoshis: number,
+    coingeckoPrices?: false | CoinGeckoPrice[],
+) => {
+    // Get XEC qty
+    const xecAmount = satoshis / 100;
+
+    if (!coingeckoPrices) {
+        return formatXecAmount(xecAmount);
+    }
+    // Get XEC price from index 0
+    const { fiat, price } = coingeckoPrices[0];
+
+    // Get fiat price
+    let fiatAmount = xecAmount * price;
+    const fiatSymbol: string = config.fiatReference[fiat] as string;
+
+    // Format fiatAmount for different tiers
+    let displayedAmount;
+    let localeOptions: Intl.NumberFormatOptions = { maximumFractionDigits: 0 };
+    let descriptor = '';
+
+    if (fiatAmount === 0) {
+        // Txs that send nothing, e.g. a one-input tx of 5.46 XEC, should keep defaults above
+    } else if (fiatAmount < 0.01) {
+        // enough decimal places to show one significant digit
+        localeOptions = {
+            minimumFractionDigits: -Math.floor(Math.log10(fiatAmount)),
+        };
+    } else if (fiatAmount < 1) {
+        // TODO two decimal places
+        localeOptions = { minimumFractionDigits: 2 };
+    }
+
+    if (fiatAmount < 1000) {
+        displayedAmount = fiatAmount;
+        descriptor = '';
+    } else if (fiatAmount < 1000000) {
+        // thousands
+        displayedAmount = fiatAmount / 1000;
+        descriptor = 'k';
+    } else if (fiatAmount < 1000000000) {
+        // millions
+        displayedAmount = fiatAmount / 1000000;
+        descriptor = 'M';
+    } else if (fiatAmount >= 1000000000) {
+        // billions or more
+        displayedAmount = fiatAmount / 1000000000;
+        descriptor = 'B';
+    }
+
+    return `${fiatSymbol}${displayedAmount!.toLocaleString(
+        'en-US',
+        localeOptions,
+    )}${descriptor}`;
+};
+export const jsonReplacer = function (key: any, value: any) {
+    if (value instanceof Map) {
+        const keyValueArray = Array.from(value.entries());
+
+        for (let i = 0; i < keyValueArray.length; i += 1) {
+            const thisKeyValue = keyValueArray[i]; // [key, value]
+            // If this is not an empty map
+            if (typeof thisKeyValue !== 'undefined') {
+                // Note: this value is an array of length 2
+                // [key, value]
+                // Check if value is a big number
+                if (thisKeyValue[1] instanceof BigNumber) {
+                    // Replace it
+                    thisKeyValue[1] = {
+                        // Note, if you use dataType: 'BigNumber', it will not work
+                        // This must be reserved
+                        // Use a term that is definitely not reserved but also recognizable as
+                        // "the dev means BigNumber here"
+                        dataType: 'BigNumberReplacer',
+                        value: thisKeyValue[1].toString(),
+                    };
+                }
+            }
+        }
+
+        return {
+            dataType: 'Map',
+            value: keyValueArray,
+        };
+    } else if (value instanceof Set) {
+        return {
+            dataType: 'Set',
+            value: Array.from(value.keys()),
+        };
+    } else {
+        return value;
+    }
+};
+export const jsonReviver = (key: any, value: any) => {
+    if (typeof value === 'object' && value !== null) {
+        if (value.dataType === 'Map') {
+            // If the map is not empty
+            if (typeof value.value[0] !== 'undefined') {
+                /* value.value is an array of keyValue arrays
+                 * e.g.
+                 * [
+                 *  [key1, value1],
+                 *  [key2, value2],
+                 *  [key3, value3],
+                 * ]
+                 */
+                // Iterate over each keyValue of the map
+                for (let i = 0; i < value.value.length; i += 1) {
+                    const thisKeyValuePair = value.value[i]; // [key, value]
+                    let thisValue = thisKeyValuePair[1];
+                    if (
+                        thisValue &&
+                        thisValue.dataType === 'BigNumberReplacer'
+                    ) {
+                        // If this is saved BigNumber, replace it with an actual BigNumber
+                        // note, you can't use thisValue = new BigNumber(thisValue.value)
+                        // Need to use this specific array entry
+                        value.value[i][1] = new BigNumber(
+                            value.value[i][1].value,
+                        );
+                    }
+                }
+            }
+            return new Map(value.value);
+        }
+        if (value.dataType === 'Set') {
+            return new Set(value.value);
+        }
+    }
+    return value;
+};
+
+/**
+ * Convert a map to a key value array
+ * Useful to generate test vectors by `console.log(mapToKeyValueArray(someMap))` in a function
+ * @param {map} map
+ * @returns array
+ */
+export const mapToKeyValueArray = (map: Map<any, any>): Array<[any, any]> => {
+    let kvArray: Array<[any, any]> = [];
+    map.forEach((value, key) => {
+        kvArray.push([key, value]);
+    });
+    return kvArray;
+};
+
+/**
+ * Assign appropriate emoji based on a balance in satoshis
+ * @param  balanceSats
+ * @returns  emoji determined by thresholds set in config
+ */
+export const getEmojiFromBalanceSats = (balanceSats: number): string => {
+    const { whaleSats, emojis } = config;
+    if (balanceSats >= whaleSats.bigWhale) {
+        return emojis.bigWhale;
+    }
+    if (balanceSats >= whaleSats.modestWhale) {
+        return emojis.modestWhale;
+    }
+    if (balanceSats >= whaleSats.shark) {
+        return emojis.shark;
+    }
+    if (balanceSats >= whaleSats.swordfish) {
+        return emojis.swordfish;
+    }
+    if (balanceSats >= whaleSats.barracuda) {
+        return emojis.barracuda;
+    }
+    if (balanceSats >= whaleSats.octopus) {
+        return emojis.octopus;
+    }
+    if (balanceSats >= whaleSats.piranha) {
+        return emojis.piranha;
+    }
+    if (balanceSats >= whaleSats.crab) {
+        return emojis.crab;
+    }
+    return emojis.shrimp;
+};
+
+/**
+ * Convert an integer-stored number with known decimals into a formatted decimal string
+ * Useful for converting token send quantities to a human-readable string
+ * @param {string} bnString an integer value as a string, e.g 100000012
+ * @param {number} decimals // in practice 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9,
+ * @returns {string} e.g. 1,000,000.12
+ */
+export const bigNumberAmountToLocaleString = (
+    bnString: string,
+    decimals: number,
+): string => {
+    const totalLength = bnString.length;
+
+    // Get the values that come after the decimal place
+    const decimalValues = decimals === 0 ? '' : bnString.slice(-1 * decimals);
+    const decimalLength = decimalValues.length;
+
+    // Get the values that come before the decimal place
+    const intValue = bnString.slice(0, totalLength - decimalLength);
+
+    // Use toLocaleString() to format the amount before the decimal place with commas
+    return `${BigInt(intValue).toLocaleString('en-US', {
+        maximumFractionDigits: 0,
+    })}${decimals !== 0 ? `.${decimalValues}` : ''}`;
+};
+
+/**
+ * Determine if an OP_RETURN's hex values include characters outside of printable ASCII range
+ * @param {string} hexString hex string containing an even number of characters
+ */
+export const containsOnlyPrintableAscii = (hexString: string): boolean => {
+    if (hexString.length % 2 !== 0) {
+        // If hexString has an odd number of characters, it is certainly not ascii
+        return false;
+    }
+
+    // Values lower than 32 are control characters (127 also control char)
+    // We could tolerate LF and CR which are in this range, but they make
+    // the msg awkward in Telegram -- so they are left out
+    const MIN_ASCII_PRINTABLE_DECIMAL = 32;
+    const MAX_ASCII_PRINTABLE_DECIMAL = 126;
+    const stack = { remainingHex: hexString };
+
+    while (stack.remainingHex.length > 0) {
+        const thisByte = parseInt(consume(stack, 1), 16);
+
+        if (
+            thisByte > MAX_ASCII_PRINTABLE_DECIMAL ||
+            thisByte < MIN_ASCII_PRINTABLE_DECIMAL
+        ) {
+            return false;
+        }
+    }
+    return true;
+};
+
+interface StakingRewardApiResponse {
+    previousBlockHash: string;
+    nextBlockHeight: number;
+    address: string;
+    minimumValue: number;
+    scriptHex: string;
+}
+/**
+ * Get the expected next staking reward winner and store it in the memory
+ * cache if the returned value targets the expected next block height.
+ * @param {number} nextBlockHeight The next block height
+ * @param {object} memoryCache The cache to store the result
+ */
+export const getNextStakingReward = async (
+    nextBlockHeight: number,
+    memoryCache: MemoryCache,
+): Promise<boolean> => {
+    let retries = 10;
+
+    while (retries > 0) {
+        try {
+            const nextStakingReward: StakingRewardApiResponse = (
+                await axios.get(config.stakingRewardApiUrl)
+            ).data;
+
+            if (nextStakingReward.nextBlockHeight === nextBlockHeight) {
+                const { address, scriptHex } = nextStakingReward;
+
+                const cachedObject: { scriptHex: string; address?: string } = {
+                    scriptHex: scriptHex,
+                };
+
+                // Note: address can be undefined
+                if (typeof address !== 'undefined') {
+                    cachedObject.address = address;
+                }
+
+                memoryCache.set(`${nextBlockHeight}`, cachedObject);
+
+                return true;
+            }
+        } catch (err) {
+            // Fallthrough
+        }
+
+        retries -= 1;
+
+        // Wait for 2 seconds before retrying
+        await new Promise(resolve => setTimeout(resolve, 2000));
+    }
+
+    console.log(
+        `Failed to fetch the expected staking reward for block ${nextBlockHeight}`,
+    );
+
+    return false;
+};
diff --git a/apps/ecash-herald/test/chronik.test.js b/apps/ecash-herald/test/chronik.test.ts
rename from apps/ecash-herald/test/chronik.test.js
rename to apps/ecash-herald/test/chronik.test.ts
--- a/apps/ecash-herald/test/chronik.test.js
+++ b/apps/ecash-herald/test/chronik.test.ts
@@ -2,123 +2,43 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const assert = require('assert');
-const { MockChronikClient } = require('../../../modules/mock-chronik-client');
-const {
+import assert from 'assert';
+import { GenesisInfo } from 'chronik-client';
+import { MockChronikClient } from '../../../modules/mock-chronik-client';
+import {
     getTokenInfoMap,
     getAllBlockTxs,
     getBlocksAgoFromChaintipByTimestamp,
-} = require('../src/chronik');
-const { tx } = require('./mocks/chronikResponses');
-// Initialize chronik on app startup
-
-const TOKEN_ID_SET = new Set([
-    '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109', // BearNip
-    'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a', // POW
-    '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', // Alita
-]);
-const TOKEN_INFO = new Map();
-TOKEN_INFO.set(
-    '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
-    {
-        tokenId:
-            '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
-        tokenType: {
-            protocol: 'SLP',
-            type: 'SLP_TOKEN_TYPE_FUNGIBLE',
-            number: 1,
-        },
-        timeFirstSeen: 0,
-        genesisInfo: {
-            tokenTicker: 'BEAR',
-            tokenName: 'BearNip',
-            url: 'https://cashtab.com/',
-            decimals: 0,
-            hash: '',
-        },
-        block: {
-            height: 782665,
-            hash: '00000000000000001239831f90580c859ec174316e91961cf0e8cde57c0d3acb',
-            timestamp: 1678408305,
-        },
-    },
-);
-TOKEN_INFO.set(
-    'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
-    {
-        tokenId:
-            'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
-        tokenType: {
-            protocol: 'SLP',
-            type: 'SLP_TOKEN_TYPE_FUNGIBLE',
-            number: 1,
-        },
-        timeFirstSeen: 0,
-        genesisInfo: {
-            tokenTicker: 'POW',
-            tokenName: 'ProofofWriting.com Token',
-            url: 'https://www.proofofwriting.com/26',
-            decimals: 0,
-            hash: '',
-        },
-        block: {
-            height: 685949,
-            hash: '0000000000000000436e71d5291d2fb067decc838dcb85a99ff6da1d28b89fad',
-            timestamp: 1620712051,
-        },
-    },
-);
-TOKEN_INFO.set(
-    '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
-    {
-        tokenId:
-            '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
-        tokenType: {
-            protocol: 'SLP',
-            type: 'SLP_TOKEN_TYPE_FUNGIBLE',
-            number: 1,
-        },
-        timeFirstSeen: 0,
-        genesisInfo: {
-            tokenTicker: 'Alita',
-            tokenName: 'Alita',
-            url: 'alita.cash',
-            decimals: 4,
-            hash: '',
-        },
-        block: {
-            height: 756373,
-            hash: '00000000000000000d62f1b66c08f0976bcdec2f08face2892ae4474b50100d9',
-            timestamp: 1662611972,
-        },
-    },
-);
-
-const TOKEN_ID_SET_BUGGED = new Set([
-    '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484', // Alita
-    '3ce19774ed20535458bb98e864168e6d7d0a68e80f166a7fb00bc9015980ce6d', // SWaP tx
-]);
+    TokenInfoMap,
+} from '../src/chronik';
+import {
+    mockTxCalls,
+    mockTokenCalls,
+    bearNipTokenId,
+    alitaTokenId,
+    powTokenId,
+    swapTxid,
+} from './mocks/mockChronikCalls';
 
 describe('chronik.js functions', function () {
     it('getTokenInfoMap returns a map of expected format given an array of tokenIds', async function () {
         // Initialize chronik mock
         const mockedChronik = new MockChronikClient();
 
-        const expectedTokenInfoMap = new Map();
         // Tell mockedChronik what responses we expect
-        // Also build the expected map result from these responses
-        TOKEN_ID_SET.forEach(tokenId => {
+        // and build expected result
+        const expectedTokenInfoMap: TokenInfoMap = new Map();
+        mockTokenCalls.forEach((tokenInfo, tokenId) => {
             mockedChronik.setMock('token', {
                 input: tokenId,
-                output: TOKEN_INFO.get(tokenId),
+                output: tokenInfo,
             });
-            expectedTokenInfoMap.set(
-                tokenId,
-                TOKEN_INFO.get(tokenId).genesisInfo,
-            );
+            expectedTokenInfoMap.set(tokenId, tokenInfo.genesisInfo);
         });
-        const tokenInfoMap = await getTokenInfoMap(mockedChronik, TOKEN_ID_SET);
+        const tokenInfoMap = await getTokenInfoMap(
+            mockedChronik,
+            new Set([alitaTokenId, bearNipTokenId, powTokenId]),
+        );
 
         assert.deepEqual(tokenInfoMap, expectedTokenInfoMap);
     });
@@ -126,21 +46,19 @@
         // Initialize chronik mock
         const mockedChronik = new MockChronikClient();
 
-        const expectedTokenInfoMap = new Map();
+        const expectedTokenInfoMap: TokenInfoMap = new Map();
         // Tell mockedChronik what responses we expect
         // Also build the expected map result from these responses
 
         // Create a set of only one token id
-        const thisTokenId = TOKEN_ID_SET.values().next().value;
-        const tokenIdSet = new Set();
-        tokenIdSet.add(thisTokenId);
+        const tokenIdSet: Set<string> = new Set([alitaTokenId]);
         mockedChronik.setMock('token', {
-            input: thisTokenId,
-            output: TOKEN_INFO.get(thisTokenId),
+            input: alitaTokenId,
+            output: mockTokenCalls.get(alitaTokenId),
         });
         expectedTokenInfoMap.set(
-            thisTokenId,
-            TOKEN_INFO.get(thisTokenId).genesisInfo,
+            alitaTokenId,
+            mockTokenCalls.get(alitaTokenId)!.genesisInfo as GenesisInfo,
         );
         const tokenInfoMap = await getTokenInfoMap(mockedChronik, tokenIdSet);
 
@@ -150,23 +68,23 @@
         // Initialize chronik mock
         const mockedChronik = new MockChronikClient();
 
-        const TOKEN_ID_ARRAY = Array.from(TOKEN_ID_SET);
+        const tokenIdSet = new Set([alitaTokenId, bearNipTokenId, powTokenId]);
         // Tell mockedChronik what responses we expect
         // Include one error response
         mockedChronik.setMock('tx', {
-            input: TOKEN_ID_ARRAY[0],
-            output: tx[TOKEN_ID_ARRAY[0]],
+            input: alitaTokenId,
+            output: mockTxCalls.get(alitaTokenId),
         });
         mockedChronik.setMock('tx', {
-            input: TOKEN_ID_ARRAY[1],
-            output: tx[TOKEN_ID_ARRAY[1]],
+            input: bearNipTokenId,
+            output: mockTxCalls.get(bearNipTokenId),
         });
         mockedChronik.setMock('tx', {
-            input: TOKEN_ID_ARRAY[2],
+            input: powTokenId,
             output: new Error('some error'),
         });
 
-        const tokenInfoMap = await getTokenInfoMap(mockedChronik, TOKEN_ID_SET);
+        const tokenInfoMap = await getTokenInfoMap(mockedChronik, tokenIdSet);
 
         assert.strictEqual(tokenInfoMap, false);
     });
@@ -174,19 +92,22 @@
         // Initialize chronik mock
         const mockedChronik = new MockChronikClient();
 
-        const TOKEN_ID_ARRAY = Array.from(TOKEN_ID_SET_BUGGED);
+        const setWithNonToken = new Set([alitaTokenId, swapTxid]);
         // Tell mockedChronik what responses we expect
         // Include one error response
         mockedChronik.setMock('tx', {
-            input: TOKEN_ID_ARRAY[0],
-            output: tx[TOKEN_ID_ARRAY[0]],
+            input: alitaTokenId,
+            output: mockTxCalls.get(alitaTokenId),
         });
         mockedChronik.setMock('tx', {
-            input: TOKEN_ID_ARRAY[1],
-            output: tx[TOKEN_ID_ARRAY[1]],
+            input: swapTxid,
+            output: mockTxCalls.get(swapTxid),
         });
 
-        const tokenInfoMap = await getTokenInfoMap(mockedChronik, TOKEN_ID_SET);
+        const tokenInfoMap = await getTokenInfoMap(
+            mockedChronik,
+            setWithNonToken,
+        );
 
         assert.strictEqual(tokenInfoMap, false);
     });
diff --git a/apps/ecash-herald/test/chronikWsHandler.test.js b/apps/ecash-herald/test/chronikWsHandler.test.ts
rename from apps/ecash-herald/test/chronikWsHandler.test.js
rename to apps/ecash-herald/test/chronikWsHandler.test.ts
--- a/apps/ecash-herald/test/chronikWsHandler.test.js
+++ b/apps/ecash-herald/test/chronikWsHandler.test.ts
@@ -2,26 +2,30 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const assert = require('assert');
-const config = require('../config');
-const cashaddr = require('ecashaddrjs');
-const unrevivedBlock = require('./mocks/block');
-const { jsonReviver, getCoingeckoApiUrl } = require('../src/utils');
-const block = JSON.parse(JSON.stringify(unrevivedBlock), jsonReviver);
-const blockInvalidated = require('./mocks/blockInvalidated');
-const {
+import assert from 'assert';
+import config from '../config';
+import cashaddr from 'ecashaddrjs';
+import unrevivedBlock from './mocks/block';
+import { jsonReviver, getCoingeckoApiUrl } from '../src/utils';
+import { blockInvalidedTgMsg } from './mocks/blockInvalidated';
+import {
     initializeWebsocket,
     parseWebsocketMessage,
-} = require('../src/chronikWsHandler');
-const { MockChronikClient } = require('../../../modules/mock-chronik-client');
-const { MockTelegramBot, mockChannelId } = require('./mocks/telegramBotMock');
-const axios = require('axios');
-const MockAdapter = require('axios-mock-adapter');
-const { caching } = require('cache-manager');
+} from '../src/chronikWsHandler';
+import { MockChronikClient } from '../../../modules/mock-chronik-client';
+import { MockTelegramBot, mockChannelId } from './mocks/telegramBotMock';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { caching, MemoryCache } from 'cache-manager';
+import { WsMsgClient } from 'chronik-client';
+import { StoredMock } from '../src/events';
+const block: StoredMock = JSON.parse(
+    JSON.stringify(unrevivedBlock),
+    jsonReviver,
+);
 
 describe('ecash-herald chronikWsHandler.js', async function () {
-    let memoryCache;
+    let memoryCache: MemoryCache;
     before(async () => {
         const CACHE_TTL = config.cacheTtlMsecs;
         memoryCache = await caching('memory', {
@@ -39,6 +43,7 @@
             mockedChronik,
             telegramBot,
             channelId,
+            memoryCache,
         );
 
         // Confirm websocket opened
@@ -56,6 +61,7 @@
             mockedChronik,
             telegramBot,
             channelId,
+            memoryCache,
         );
 
         // Confirm websocket opened
@@ -88,7 +94,7 @@
             const thisUnsupportedMsg = unsupportedWebsocketMsgs[i];
             const result = await parseWebsocketMessage(
                 mockedChronik,
-                thisUnsupportedMsg,
+                thisUnsupportedMsg as WsMsgClient,
                 telegramBot,
                 channelId,
                 memoryCache,
@@ -117,7 +123,6 @@
         outputScriptInfoMap.forEach((info, outputScript) => {
             let { type, hash } =
                 cashaddr.getTypeAndHashFromOutputScript(outputScript);
-            type = type.toLowerCase();
             const { utxos } = info;
             mockedChronik.setScript(type, hash);
             mockedChronik.setUtxos(type, hash, { outputScript, utxos });
@@ -172,7 +177,7 @@
 
         const result = await parseWebsocketMessage(
             mockedChronik,
-            mockWsMsg,
+            mockWsMsg as WsMsgClient,
             telegramBot,
             channelId,
             memoryCache,
@@ -261,7 +266,7 @@
 
         const result = await parseWebsocketMessage(
             mockedChronik,
-            mockWsMsg,
+            mockWsMsg as WsMsgClient,
             telegramBot,
             channelId,
             memoryCache,
@@ -350,7 +355,7 @@
 
         const result = await parseWebsocketMessage(
             mockedChronik,
-            mockWsMsg,
+            mockWsMsg as WsMsgClient,
             telegramBot,
             channelId,
             memoryCache,
@@ -368,9 +373,9 @@
         // Mock a chronik websocket msg of correct format
         const mockWsMsg = {
             msgType: 'BLK_INVALIDATED',
-            blockHash: thisBlock.blockTxs[0].block.hash,
-            blockHeight: thisBlock.blockTxs[0].block.height,
-            blockTimestamp: thisBlock.blockTxs[0].block.timestamp,
+            blockHash: thisBlock.blockTxs[0].block!.hash,
+            blockHeight: thisBlock.blockTxs[0].block!.height,
+            blockTimestamp: thisBlock.blockTxs[0].block!.timestamp,
             coinbaseData: {
                 scriptsig: thisBlock.blockTxs[0].inputs[0].inputScript,
                 outputs: thisBlock.blockTxs[0].outputs,
@@ -381,7 +386,7 @@
 
         const result = await parseWebsocketMessage(
             mockedChronik,
-            mockWsMsg,
+            mockWsMsg as WsMsgClient,
             telegramBot,
             channelId,
             memoryCache,
@@ -392,7 +397,7 @@
         let msgSuccess = {
             success: true,
             channelId,
-            msg: blockInvalidated.tgMsg,
+            msg: blockInvalidedTgMsg,
             options: config.tgMsgOptions,
         };
 
diff --git a/apps/ecash-herald/test/events.test.js b/apps/ecash-herald/test/events.test.ts
rename from apps/ecash-herald/test/events.test.js
rename to apps/ecash-herald/test/events.test.ts
--- a/apps/ecash-herald/test/events.test.js
+++ b/apps/ecash-herald/test/events.test.ts
@@ -2,27 +2,30 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const assert = require('assert');
-const config = require('../config');
-const unrevivedBlock = require('./mocks/block');
-const { jsonReviver, getCoingeckoApiUrl } = require('../src/utils');
-const block = JSON.parse(JSON.stringify(unrevivedBlock), jsonReviver);
-const blockInvalidated = require('./mocks/blockInvalidated');
-const cashaddr = require('ecashaddrjs');
-const {
+import assert from 'assert';
+import config from '../config';
+import unrevivedBlock from './mocks/block';
+import { jsonReviver, getCoingeckoApiUrl } from '../src/utils';
+import { blockInvalidedTgMsg } from './mocks/blockInvalidated';
+import cashaddr from 'ecashaddrjs';
+import {
     handleBlockFinalized,
     handleBlockInvalidated,
-} = require('../src/events');
-const { MockChronikClient } = require('../../../modules/mock-chronik-client');
-const { MockTelegramBot, mockChannelId } = require('./mocks/telegramBotMock');
-const axios = require('axios');
-const MockAdapter = require('axios-mock-adapter');
-const { caching } = require('cache-manager');
-const FakeTimers = require('@sinonjs/fake-timers');
+    StoredMock,
+} from '../src/events';
+import { MockChronikClient } from '../../../modules/mock-chronik-client';
+import { MockTelegramBot, mockChannelId } from './mocks/telegramBotMock';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { caching, MemoryCache } from 'cache-manager';
+import FakeTimers from '@sinonjs/fake-timers';
+const block: StoredMock = JSON.parse(
+    JSON.stringify(unrevivedBlock),
+    jsonReviver,
+);
 
 describe('ecash-herald events.js', async function () {
-    let memoryCache;
+    let memoryCache: MemoryCache;
     before(async () => {
         const CACHE_TTL = config.cacheTtlMsecs;
         memoryCache = await caching('memory', {
@@ -31,7 +34,7 @@
         });
     });
 
-    let clock;
+    let clock: any;
     beforeEach(() => {
         clock = FakeTimers.install();
     });
@@ -56,7 +59,6 @@
         outputScriptInfoMap.forEach((info, outputScript) => {
             let { type, hash } =
                 cashaddr.getTypeAndHashFromOutputScript(outputScript);
-            type = type.toLowerCase();
             const { utxos } = info;
             mockedChronik.setScript(type, hash);
             mockedChronik.setUtxos(type, hash, { outputScript, utxos });
@@ -314,9 +316,9 @@
             mockedChronik,
             telegramBot,
             channelId,
-            thisBlock.blockTxs[0].block.hash,
-            thisBlock.blockTxs[0].block.height,
-            thisBlock.blockTxs[0].block.timestamp,
+            thisBlock.blockTxs[0].block!.hash,
+            thisBlock.blockTxs[0].block!.height,
+            thisBlock.blockTxs[0].block!.timestamp,
             {
                 scriptsig: thisBlock.blockTxs[0].inputs[0].inputScript,
                 outputs: thisBlock.blockTxs[0].outputs,
@@ -330,7 +332,7 @@
         let msgSuccess = {
             success: true,
             channelId,
-            msg: blockInvalidated.tgMsg,
+            msg: blockInvalidedTgMsg,
             options: config.tgMsgOptions,
         };
 
diff --git a/apps/ecash-herald/test/fixtures/invalidatedBlocks.js b/apps/ecash-herald/test/fixtures/invalidatedBlocks.ts
rename from apps/ecash-herald/test/fixtures/invalidatedBlocks.js
rename to apps/ecash-herald/test/fixtures/invalidatedBlocks.ts
--- a/apps/ecash-herald/test/fixtures/invalidatedBlocks.js
+++ b/apps/ecash-herald/test/fixtures/invalidatedBlocks.ts
@@ -2,8 +2,7 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-module.exports = [
+const invalidatedBlockFixture = [
     {
         hash: '00000000000000000692216bd4f235fc2cd98872640ba6a3bec0130cbfe59a13',
         height: 865428,
@@ -192,3 +191,5 @@
         expectedRejectReason: 'unknown',
     },
 ];
+
+export default invalidatedBlockFixture;
diff --git a/apps/ecash-herald/test/fixtures/miners.js b/apps/ecash-herald/test/fixtures/miners.ts
rename from apps/ecash-herald/test/fixtures/miners.js
rename to apps/ecash-herald/test/fixtures/miners.ts
--- a/apps/ecash-herald/test/fixtures/miners.js
+++ b/apps/ecash-herald/test/fixtures/miners.ts
@@ -2,8 +2,7 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-module.exports = [
+const minerTestFixtures = [
     {
         height: 791160,
         coinbaseHex:
@@ -308,3 +307,5 @@
         parsed: 'nodeStratum',
     },
 ];
+
+export default minerTestFixtures;
diff --git a/apps/ecash-herald/test/fixtures/stakers.js b/apps/ecash-herald/test/fixtures/stakers.ts
rename from apps/ecash-herald/test/fixtures/stakers.js
rename to apps/ecash-herald/test/fixtures/stakers.ts
--- a/apps/ecash-herald/test/fixtures/stakers.js
+++ b/apps/ecash-herald/test/fixtures/stakers.ts
@@ -2,27 +2,26 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-module.exports = [
+const stakerTestFixtures = [
     {
         coinbaseTx: {
             outputs: [
                 {
-                    value: '362501148',
+                    value: 362501148,
                     outputScript:
                         '76a9141b1bbcb888b4440a573427f526cb221f657318cf88ac',
                     slpToken: undefined,
                     spentBy: undefined,
                 },
                 {
-                    value: '200000632',
+                    value: 200000632,
                     outputScript:
                         'a914d37c4c809fe9840e7bfa77b86bd47163f6fb6c6087',
                     slpToken: undefined,
                     spentBy: undefined,
                 },
                 {
-                    value: '62500197',
+                    value: 62500197,
                     outputScript:
                         '76a914066f83c9a49e2639b5f0fb03f4da1b387c7e8ad188ac',
                     slpToken: undefined,
@@ -44,14 +43,14 @@
         coinbaseTx: {
             outputs: [
                 {
-                    value: '575000000',
+                    value: 575000000,
                     outputScript:
                         '76a914a24e2b67689c3753983d3b408bc7690d31b1b74d88ac',
                     slpToken: undefined,
                     spentBy: undefined,
                 },
                 {
-                    value: '50000000',
+                    value: 50000000,
                     outputScript:
                         'a914d37c4c809fe9840e7bfa77b86bd47163f6fb6c6087',
                     slpToken: undefined,
@@ -102,3 +101,5 @@
         staker: false,
     },
 ];
+
+export default stakerTestFixtures;
diff --git a/apps/ecash-herald/test/main.test.js b/apps/ecash-herald/test/main.test.ts
rename from apps/ecash-herald/test/main.test.js
rename to apps/ecash-herald/test/main.test.ts
--- a/apps/ecash-herald/test/main.test.js
+++ b/apps/ecash-herald/test/main.test.ts
@@ -2,20 +2,18 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const assert = require('assert');
-const { main } = require('../src/main');
-const { MockChronikClient } = require('../../../modules/mock-chronik-client');
-const { MockTelegramBot, mockChannelId } = require('./mocks/telegramBotMock');
+import assert from 'assert';
+import { main } from '../src/main';
+import { MockChronikClient } from '../../../modules/mock-chronik-client';
+import { MockTelegramBot, mockChannelId } from './mocks/telegramBotMock';
 
 describe('ecash-herald main.js', async function () {
     it('main() starts the app on successful websocket connection', async function () {
         // Initialize chronik mock
         const mockedChronik = new MockChronikClient();
-        const telegramBot = MockTelegramBot;
         const channelId = mockChannelId;
 
-        await main(mockedChronik, telegramBot, channelId);
+        await main(mockedChronik, new MockTelegramBot(), channelId);
 
         // Confirm websocket opened
         assert.strictEqual(mockedChronik.wsWaitForOpenCalled, true);
diff --git a/apps/ecash-herald/test/mocks/appTxSamples.js b/apps/ecash-herald/test/mocks/appTxSamples.ts
rename from apps/ecash-herald/test/mocks/appTxSamples.js
rename to apps/ecash-herald/test/mocks/appTxSamples.ts
--- a/apps/ecash-herald/test/mocks/appTxSamples.js
+++ b/apps/ecash-herald/test/mocks/appTxSamples.ts
@@ -1,11 +1,107 @@
 // Copyright (c) 2023 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
-'use strict';
 // Disable as these are "used" to match the expected tg format
 /* eslint no-useless-escape: 0 */
-const opReturn = require('../../constants/op_return');
-module.exports = {
+import { GenesisInfo } from 'chronik-client';
+import opReturn from '../../constants/op_return';
+import { CoinGeckoPrice } from '../../src/utils';
+
+interface SwapMock {
+    hex: string;
+    msg: string;
+    stackArray: string[];
+    tokenId: false | string;
+    tokenInfo: false | GenesisInfo;
+}
+
+type RecipientEntry = [string, number];
+
+// Define the type for the entire array
+type RecipientsArray = RecipientEntry[];
+interface AirdropMock {
+    txid: string;
+    hex: string;
+    stackArray: string[];
+    airdropSendingAddress: string;
+    airdropRecipientsKeyValueArray: RecipientsArray;
+    tokenId: false | string;
+    tokenInfo: false | GenesisInfo;
+    coingeckoPrices: CoinGeckoPrice[];
+    msg: string;
+    msgApiFailure: string;
+}
+
+interface CashtabMsgMock {
+    txid: string;
+    hex: string;
+    stackArray: string[];
+    msg: string;
+}
+interface EncryptedCashtabMsgMock {
+    txid: string;
+    hex: string;
+    sendingAddress: string;
+    xecReceivingOutputsKeyValueArray: RecipientsArray;
+    stackArray: string[];
+    coingeckoPrices: CoinGeckoPrice[];
+    msg: string;
+    msgApiFailure: string;
+}
+interface SlpTwoPushVector {
+    push: string;
+    msg: string;
+}
+interface SlpTwoTxVector {
+    txid: string;
+    hex: string;
+    emppStackArray: string[];
+    msg: string;
+}
+interface AliasRegistrationVector {
+    txid: string;
+    hex: string;
+    stackArray: string[];
+    msg: string;
+}
+interface PayButtonTx {
+    txid: string;
+    hex: string;
+    stackArray: string[];
+    msg: string;
+}
+interface PaywallTxVector {
+    txid: string;
+    hex: string;
+    stackArray: string[];
+    msg: string;
+}
+interface AuthTxVector {
+    txid: string;
+    hex: string;
+    stackArray: string[];
+    msg: string;
+}
+interface AppTxSamples {
+    swaps: SwapMock[];
+    airdrops: AirdropMock[];
+    cashtabMsgs: CashtabMsgMock[];
+    encryptedCashtabMsgs: EncryptedCashtabMsgMock[];
+    slp2PushVectors: SlpTwoPushVector[];
+    slp2TxVectors: SlpTwoTxVector[];
+    aliasRegistrations: AliasRegistrationVector[];
+    payButtonTxs: PayButtonTx[];
+    paywallTxs: PaywallTxVector[];
+    authenticationTxs: AuthTxVector[];
+}
+
+const BASE_GENESIS_INFO: GenesisInfo = {
+    tokenName: 'test',
+    tokenTicker: 'test',
+    url: 'https://cashtab.com/',
+    decimals: 0,
+};
+const appTxSamples: AppTxSamples = {
     // https://github.com/vinarmani/swap-protocol/blob/master/swap-protocol-spec.md
     swaps: [
         // 0101 https://explorer.e.cash/tx/b03883ca0b106ea5e7113d6cbe46b9ec37ac6ba437214283de2d9cf2fbdc997f
@@ -26,7 +122,7 @@
             ],
             tokenId:
                 '4de69e374a8ed21cbddd47f2338cc0f479dc58daa2bbe11cd604ca488eca0ddf',
-            tokenInfo: { tokenTicker: 'SPICE' },
+            tokenInfo: { ...BASE_GENESIS_INFO, tokenTicker: 'SPICE' },
         },
         // 0101 ascii example https://explorer.e.cash/tx/2308e1c36d8355edd86dd7d643da41994ab780c852fdfa8d032b1a337bf18bb6
         // Sell price is hex, min price is ascii
@@ -48,7 +144,7 @@
             ],
             tokenId:
                 'fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa',
-            tokenInfo: { tokenTicker: 'GRP' },
+            tokenInfo: { ...BASE_GENESIS_INFO, tokenTicker: 'GRP' },
         },
         // 0101 ascii 2, https://explorer.e.cash/tx/dfad6b85a8f0e4b338f4f3bc67d2b7f73fb27f82b6d71ad3e2be955643fe6e42
         // Both are ascii
@@ -70,7 +166,7 @@
             ],
             tokenId:
                 'b46c6e0a485f0fade147696e54d3b523071860fd745fbfa97a515846bd3019a6',
-            tokenInfo: { tokenTicker: 'BTCinu' },
+            tokenInfo: { ...BASE_GENESIS_INFO, tokenTicker: 'BTCinu' },
         },
         // 0101 ascii 3, https://explorer.e.cash/tx/e52daad4006ab27b9e103c7ca0e58bd483f8c6c377ba5075cf7f412fbb272971
         // Recent gorbeious tx
@@ -92,7 +188,7 @@
             ],
             tokenId:
                 'aebcae9afe88d61d8b8ed7b8c83c7c2a555583bf8f8591c94a2c9eb82f34816c',
-            tokenInfo: { tokenTicker: 'GORB' },
+            tokenInfo: { ...BASE_GENESIS_INFO, tokenTicker: 'GORB' },
         },
         // 0102 https://explorer.e.cash/tx/70c2842e1b2c7eb49ee69cdecf2d6f3cd783c307c4cbeef80f176159c5891484
         // Note, this example uses faulty pushdata at the end
@@ -162,7 +258,7 @@
                 '00',
             ],
             tokenId: false,
-            tokenInfo: { tokenTicker: 'SPICE' },
+            tokenInfo: { ...BASE_GENESIS_INFO, tokenTicker: 'SPICE' },
         },
         // Mod 0101 https://explorer.e.cash/tx/b03883ca0b106ea5e7113d6cbe46b9ec37ac6ba437214283de2d9cf2fbdc997f
         {
@@ -227,8 +323,8 @@
             tokenInfo: {
                 tokenTicker: 'ePLK',
                 tokenName: 'ePalinka',
-                tokenDocumentUrl: 'http://www.hungarikum.hu/en',
-                tokenDocumentHash: '',
+                url: 'http://www.hungarikum.hu/en',
+                hash: '',
                 decimals: 3,
             },
             coingeckoPrices: [
@@ -265,8 +361,8 @@
             tokenInfo: {
                 tokenTicker: 'DET',
                 tokenName: 'Dividend eToken',
-                tokenDocumentUrl: 'https://cashtab.com/',
-                tokenDocumentHash: '',
+                url: 'https://cashtab.com/',
+                hash: '',
                 decimals: 8,
             },
             coingeckoPrices: [
@@ -634,3 +730,5 @@
         },
     ],
 };
+
+export default appTxSamples;
diff --git a/apps/ecash-herald/test/mocks/block.js b/apps/ecash-herald/test/mocks/block.ts
rename from apps/ecash-herald/test/mocks/block.js
rename to apps/ecash-herald/test/mocks/block.ts
--- a/apps/ecash-herald/test/mocks/block.js
+++ b/apps/ecash-herald/test/mocks/block.ts
@@ -2,9 +2,7 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-
-module.exports = {
+const mockedBlock: any = {
     blockTxs: [
         {
             txid: '0bf6e9cd974cd5fc6fbbf739a42447d41a301890e2db242295c64df63dc3ee7e',
@@ -6096,10 +6094,12 @@
         ],
     },
     blockSummaryTgMsgs: [
-        '📦<a href="https://explorer.e.cash/block/0000000000000000000000000000000000000000000000000000000000000000">819346</a> | 27 txs | unknown, ...863u\n⏰ 20,654 blocks until eCash halving\n💰$63 to <a href="https://explorer.e.cash/address/ecash:qrpkjsd0fjxd7m332mmlu9px6pwkzaufpcn2u7jcwt">qrp...cwt</a>\n1 XEC = $0.0001\n1 BTC = $30,000\n1 ETH = $2,000\n\n<b>1 new eToken created</b>\n🧪<a href="https://explorer.e.cash/tx/010114b9bbe776def1a512ad1e96a4a06ec4c34fc79bcb5d908845f5102f6b0f">LOLLY</a> (Lolly) <a href="undefined">[doc]</a>\n\n<a href="https://cashtab.com/">Cashtab</a>\n<b>3</b> <a href="https://explorer.e.cash/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">CACHET</a> rewards\n<b>2</b> new users received <b>84 XEC</b>\n\n2 txs sent 10.2000 <a href="https://explorer.e.cash/tx/cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145">Credo In Unum Deo (CRD)</a>\n1 tx sent 100.00 <a href="https://explorer.e.cash/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">Cachet (CACHET)</a>\n1 tx sent 55 <a href="https://explorer.e.cash/tx/98183238638ecb4ddc365056e22de0e8a05448c1e6084bae247fae5a74ad4f48">Delta Variant Variants (DVV)</a>\n1 tx sent 10.9876543 <a href="https://explorer.e.cash/tx/7443f7c831cdf2b2b04d5f0465ed0bcf348582675b0e4f17906438c232c22f3d">Test Token With Exceptionally Long Name For CSS And Style Revisions (WDT)</a>\n1 tx sent 5,000,000.00 <a href="https://explorer.e.cash/tx/fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa">GRUMPY (GRP)</a>\n1 tx sent 356.6918 <a href="https://explorer.e.cash/tx/7e7dacd72dcdb14e00a03dd3aff47f019ed51a6f1f4e4f532ae50692f62bc4e5">Badger Universal Token (BUX)</a>\n\n<b>1 eToken burn tx</b>\n🔥qp9...et0 <a href="https://explorer.e.cash/tx/6b139007a0649f99a1a099c7c924716ee1920f74ea83111f6426854d4c3c3c79">burned</a> 1.00 <a href="https://explorer.e.cash/tx/fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa">GRP</a> \n\n<b>9 app txs</b>\n⚛️<a href="https://explorer.e.cash/tx/d5be7a4b483f9fdbbe3bf46cfafdd0100d5dbeee0b972f4dabc8ae9d9962fa55">CashFusion:</a> Fused $1k from 64 inputs into 63 outputs\n❓<a href="https://explorer.e.cash/tx/b5782d3a3b55e5ee9e4330a969c2891042ae05fafab7dc05cd14da63e7242f8e">unknown:</a> 0x663ddd99990bcd9699...\n❓<a href="https://explorer.e.cash/tx/9094e1aab7ac73c680bf66e78cc8311831b3d813e608bff1e07b1854855fc0f1">unknown:</a> =:ETH.ETH:0xa9aaF30F65955C69c16B3345B51D426D9B88Ba87:841321:tr:0\n🪂<a href="https://explorer.e.cash/tx/7a0d6ae3384e293183478f681f51a77ef4c71f29957199364bb9ba4d8e1938be">Airdrop:</a> qru...jys airdropped $5 to 13 holders of <a href="https://explorer.e.cash/tx/b76878b29eff39c8c28aaed7d18a166c20057c43beeb90b630264470983c984a">eAfrica</a>|Stay with us, eCash Africa is the next big community in the African cryptosphere. \n🖋<a href="https://explorer.e.cash/tx/d02d94a1a520877c60d1e3026c3e85f8995d48d7b90140f83e24ede592c30306">Cashtab Msg, $1 for $0.0005:</a> I like eCash\n🔏<a href="https://explorer.e.cash/tx/1083da7ead4779fbab5c5e8291bb7a37abaf4f97f5ff99ee654759b2eaee445b">Cashtab Encrypted:</a> qq9...fgx sent an encrypted message and $0.002 to qzv...fed\n👾<a href="https://explorer.e.cash/tx/22135bb69435023a84c80b1b93b31fc8898c3507eaa70569ed038f32d59599a9">Alias (beta):</a> doge2\n🤳<a href="https://explorer.e.cash/tx/ad44bf5e214ab71bb60a2eee165f368c139cd49c2380c3352f0a4fffc746b36a">SWaP:</a> Signal|SLP Atomic Swap|<a href="https://explorer.e.cash/tx/aebcae9afe88d61d8b8ed7b8c83c7c2a555583bf8f8591c94a2c9eb82f34816c">GORB</a>|SELL for 159,883.54 XEC|Min trade: 0 XEC\n🗞<a href="https://explorer.e.cash/tx/a8c348539a1470b28b9f99693994b918b475634352994dddce80ad544e871b3a">memo:</a> Reply to memo|<a href="https://explorer.e.cash/tx/eae5710aba50a0a22b266ddbb445e05b7348d15c88cbc2e012a91a09bec3861a">memo</a>|Twitter keeps turning their API on and off. Sometimes it works, sometimes it doesn\'t. Feature to create tweets from memo may work again at some point.\n\n<b>3 eCash txs</b>',
+        '📦<a href="https://explorer.e.cash/block/0000000000000000000000000000000000000000000000000000000000000000">819346</a> | 27 txs | unknown, ...863u\n⏰ 20,654 blocks until eCash halving\n💰$63 to <a href="https://explorer.e.cash/address/ecash:qrpkjsd0fjxd7m332mmlu9px6pwkzaufpcn2u7jcwt">qrp...cwt</a>\n1 XEC = $0.0001\n1 BTC = $30,000\n1 ETH = $2,000\n\n<b>1 new eToken created</b>\n🧪<a href="https://explorer.e.cash/tx/010114b9bbe776def1a512ad1e96a4a06ec4c34fc79bcb5d908845f5102f6b0f">LOLLY</a> (Lolly) <a href="https://cashtab.com/">[doc]</a>\n\n<a href="https://cashtab.com/">Cashtab</a>\n<b>3</b> <a href="https://explorer.e.cash/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">CACHET</a> rewards\n<b>2</b> new users received <b>84 XEC</b>\n\n2 txs sent 10.2000 <a href="https://explorer.e.cash/tx/cdcdcdcdcdc9dda4c92bb1145aa84945c024346ea66fd4b699e344e45df2e145">Credo In Unum Deo (CRD)</a>\n1 tx sent 100.00 <a href="https://explorer.e.cash/tx/aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb1">Cachet (CACHET)</a>\n1 tx sent 55 <a href="https://explorer.e.cash/tx/98183238638ecb4ddc365056e22de0e8a05448c1e6084bae247fae5a74ad4f48">Delta Variant Variants (DVV)</a>\n1 tx sent 10.9876543 <a href="https://explorer.e.cash/tx/7443f7c831cdf2b2b04d5f0465ed0bcf348582675b0e4f17906438c232c22f3d">Test Token With Exceptionally Long Name For CSS And Style Revisions (WDT)</a>\n1 tx sent 5,000,000.00 <a href="https://explorer.e.cash/tx/fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa">GRUMPY (GRP)</a>\n1 tx sent 356.6918 <a href="https://explorer.e.cash/tx/7e7dacd72dcdb14e00a03dd3aff47f019ed51a6f1f4e4f532ae50692f62bc4e5">Badger Universal Token (BUX)</a>\n\n<b>1 eToken burn tx</b>\n🔥qp9...et0 <a href="https://explorer.e.cash/tx/6b139007a0649f99a1a099c7c924716ee1920f74ea83111f6426854d4c3c3c79">burned</a> 1.00 <a href="https://explorer.e.cash/tx/fb4233e8a568993976ed38a81c2671587c5ad09552dedefa78760deed6ff87aa">GRP</a> \n\n<b>9 app txs</b>\n⚛️<a href="https://explorer.e.cash/tx/d5be7a4b483f9fdbbe3bf46cfafdd0100d5dbeee0b972f4dabc8ae9d9962fa55">CashFusion:</a> Fused $1k from 64 inputs into 63 outputs\n❓<a href="https://explorer.e.cash/tx/b5782d3a3b55e5ee9e4330a969c2891042ae05fafab7dc05cd14da63e7242f8e">unknown:</a> 0x663ddd99990bcd9699...\n❓<a href="https://explorer.e.cash/tx/9094e1aab7ac73c680bf66e78cc8311831b3d813e608bff1e07b1854855fc0f1">unknown:</a> =:ETH.ETH:0xa9aaF30F65955C69c16B3345B51D426D9B88Ba87:841321:tr:0\n🪂<a href="https://explorer.e.cash/tx/7a0d6ae3384e293183478f681f51a77ef4c71f29957199364bb9ba4d8e1938be">Airdrop:</a> qru...jys airdropped $5 to 13 holders of <a href="https://explorer.e.cash/tx/b76878b29eff39c8c28aaed7d18a166c20057c43beeb90b630264470983c984a">eAfrica</a>|Stay with us, eCash Africa is the next big community in the African cryptosphere. \n🖋<a href="https://explorer.e.cash/tx/d02d94a1a520877c60d1e3026c3e85f8995d48d7b90140f83e24ede592c30306">Cashtab Msg, $1 for $0.0005:</a> I like eCash\n🔏<a href="https://explorer.e.cash/tx/1083da7ead4779fbab5c5e8291bb7a37abaf4f97f5ff99ee654759b2eaee445b">Cashtab Encrypted:</a> qq9...fgx sent an encrypted message and $0.002 to qzv...fed\n👾<a href="https://explorer.e.cash/tx/22135bb69435023a84c80b1b93b31fc8898c3507eaa70569ed038f32d59599a9">Alias (beta):</a> doge2\n🤳<a href="https://explorer.e.cash/tx/ad44bf5e214ab71bb60a2eee165f368c139cd49c2380c3352f0a4fffc746b36a">SWaP:</a> Signal|SLP Atomic Swap|<a href="https://explorer.e.cash/tx/aebcae9afe88d61d8b8ed7b8c83c7c2a555583bf8f8591c94a2c9eb82f34816c">GORB</a>|SELL for 159,883.54 XEC|Min trade: 0 XEC\n🗞<a href="https://explorer.e.cash/tx/a8c348539a1470b28b9f99693994b918b475634352994dddce80ad544e871b3a">memo:</a> Reply to memo|<a href="https://explorer.e.cash/tx/eae5710aba50a0a22b266ddbb445e05b7348d15c88cbc2e012a91a09bec3861a">memo</a>|Twitter keeps turning their API on and off. Sometimes it works, sometimes it doesn\'t. Feature to create tweets from memo may work again at some point.\n\n<b>3 eCash txs</b>',
         '💸<a href="https://explorer.e.cash/tx/4f33c81d95641eb0f80e793dc96c58a2438f9bb1f18750d8fb3b56c28cd25035">$584k for $0.0003</a> 🐳 Binance ➡️ itself\n💸<a href="https://explorer.e.cash/tx/f5d4c112cfd22701226ba050cacfacc3aff570964c6196f67e326fc3224300a2">$107k for $0.003</a> qp7...sr4 ➡️ 🦀qzj...ksg\n💸<a href="https://explorer.e.cash/tx/413b57617d2c497b137d31c53151fee595415ec273ef7a111160da8093147ed8">$0.0005 for $0.0005</a>',
     ],
     blockSummaryTgMsgsApiFailure: [
         '📦<a href="https://explorer.e.cash/block/0000000000000000000000000000000000000000000000000000000000000000">819346</a> | 27 txs | unknown, ...863u\n⏰ 20,654 blocks until eCash halving\n💰625k XEC to <a href="https://explorer.e.cash/address/ecash:qrpkjsd0fjxd7m332mmlu9px6pwkzaufpcn2u7jcwt">qrp...cwt</a>\n\n<b>9 app txs</b>\n⚛️<a href="https://explorer.e.cash/tx/d5be7a4b483f9fdbbe3bf46cfafdd0100d5dbeee0b972f4dabc8ae9d9962fa55">CashFusion:</a> Fused 13M XEC from 64 inputs into 63 outputs\n❓<a href="https://explorer.e.cash/tx/b5782d3a3b55e5ee9e4330a969c2891042ae05fafab7dc05cd14da63e7242f8e">unknown:</a> 0x663ddd99990bcd9699...\n❓<a href="https://explorer.e.cash/tx/9094e1aab7ac73c680bf66e78cc8311831b3d813e608bff1e07b1854855fc0f1">unknown:</a> =:ETH.ETH:0xa9aaF30F65955C69c16B3345B51D426D9B88Ba87:841321:tr:0\n🪂<a href="https://explorer.e.cash/tx/7a0d6ae3384e293183478f681f51a77ef4c71f29957199364bb9ba4d8e1938be">Airdrop:</a> qru...jys airdropped 45k XEC to 13 holders of <a href="https://explorer.e.cash/tx/b76878b29eff39c8c28aaed7d18a166c20057c43beeb90b630264470983c984a">b76...84a</a>|Stay with us, eCash Africa is the next big community in the African cryptosphere. \n🖋<a href="https://explorer.e.cash/tx/d02d94a1a520877c60d1e3026c3e85f8995d48d7b90140f83e24ede592c30306">Cashtab Msg, 10k XEC for 4.79 XEC:</a> I like eCash\n🔏<a href="https://explorer.e.cash/tx/1083da7ead4779fbab5c5e8291bb7a37abaf4f97f5ff99ee654759b2eaee445b">Cashtab Encrypted:</a> qq9...fgx sent an encrypted message and 20 XEC to qzv...fed\n👾<a href="https://explorer.e.cash/tx/22135bb69435023a84c80b1b93b31fc8898c3507eaa70569ed038f32d59599a9">Alias (beta):</a> doge2\n🤳<a href="https://explorer.e.cash/tx/ad44bf5e214ab71bb60a2eee165f368c139cd49c2380c3352f0a4fffc746b36a">SWaP:</a> Signal|SLP Atomic Swap|<a href="https://explorer.e.cash/tx/aebcae9afe88d61d8b8ed7b8c83c7c2a555583bf8f8591c94a2c9eb82f34816c">Unknown Token</a>|SELL for 159,883.54 XEC|Min trade: 0 XEC\n🗞<a href="https://explorer.e.cash/tx/a8c348539a1470b28b9f99693994b918b475634352994dddce80ad544e871b3a">memo:</a> Reply to memo|<a href="https://explorer.e.cash/tx/eae5710aba50a0a22b266ddbb445e05b7348d15c88cbc2e012a91a09bec3861a">memo</a>|Twitter keeps turning their API on and off. Sometimes it works, sometimes it doesn\'t. Feature to create tweets from memo may work again at some point.\n\n<b>17 eCash txs</b>\n💸<a href="https://explorer.e.cash/tx/4f33c81d95641eb0f80e793dc96c58a2438f9bb1f18750d8fb3b56c28cd25035">6B XEC for 2.6 XEC</a>\n💸<a href="https://explorer.e.cash/tx/f5d4c112cfd22701226ba050cacfacc3aff570964c6196f67e326fc3224300a2">1B XEC for 29 XEC</a>\n💸<a href="https://explorer.e.cash/tx/d8fe456c89357c23ac6d240fe9319ce9ba393c9c3833631046a265ca7c8349e6">42 XEC for 2.19 XEC</a>\n💸<a href="https://explorer.e.cash/tx/083b7862bae48e78549ccf63833896f5f4f5bdef5c380a108fa99cdb64261fa3">42 XEC for 2.19 XEC</a>\n💸<a href="https://explorer.e.cash/tx/45ec66bc2440d2f94fa2c645e20a44f6fab7c397053ce77a95484c6053104cdc">31 XEC for 24 XEC</a>\n💸<a href="https://explorer.e.cash/tx/004e018dd98520aa722ee76c608771dd578a044f38103a8298f25e6ffbc7c3ba">5.46 XEC for 4.81 XEC</a>\n💸<a href="https://explorer.e.cash/tx/0110cd886ecd2d9570e98b7501cd039f4e5352d69659a46f1a49cc19c1869701">5.46 XEC for 4.81 XEC</a>\n💸<a href="https://explorer.e.cash/tx/327101f6f3b740280a6e9fbd8edc41f4f0500633672975a5974a4147c94016a5">5.46 XEC for 4.81 XEC</a>\n💸<a href="https://explorer.e.cash/tx/aa13c6f214ff58f36ed5e108a7f36d8f98729c50186b27a53b989c7f36fbf517">5.46 XEC for 4.81 XEC</a>\n💸<a href="https://explorer.e.cash/tx/6ffcc83e76226bd32821cc6862ce9b363b22594247a4e73ccf3701b0023592b2">5.46 XEC for 11 XEC</a>\n💸<a href="https://explorer.e.cash/tx/fb70df00c07749082756054522d3f08691fd9caccd0e0abf736df23d22845a6e">5.46 XEC for 11 XEC</a>\n💸<a href="https://explorer.e.cash/tx/25345b0bf921a2a9080c647768ba440bbe84499f4c7773fba8a1b03e88ae7fe7">5.46 XEC for 11 XEC</a>\n...and <a href="https://explorer.e.cash/block/0000000000000000000000000000000000000000000000000000000000000000">5 more</a>',
     ],
 };
+
+export default mockedBlock;
diff --git a/apps/ecash-herald/test/mocks/blockInvalidated.js b/apps/ecash-herald/test/mocks/blockInvalidated.js
deleted file mode 100644
--- a/apps/ecash-herald/test/mocks/blockInvalidated.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// Copyright (c) 2024 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-'use strict';
-
-module.exports = {
-    // This needs to be maintained in sync with the block from blocks.js should
-    // it change.
-    tgMsg: 'Block invalidated by avalanche\n\nHeight: 819,346\n\nHash: 00000000000000001d985578bc11edf9bbfee8daad0f39500e3f429c72fcf282\nTimestamp: 1700613264\nMined by unknown, ...863u\nStaking reward winner: ecash:qrpkjsd0fjxd7m332mmlu9px6pwkzaufpcn2u7jcwt\nGuessed reject reason: unknown',
-};
diff --git a/apps/ecash-herald/test/mocks/blockInvalidated.ts b/apps/ecash-herald/test/mocks/blockInvalidated.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/test/mocks/blockInvalidated.ts
@@ -0,0 +1,8 @@
+// Copyright (c) 2024 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+// This needs to be maintained in sync with the block from blocks.js should
+// it change.
+export const blockInvalidedTgMsg: string =
+    'Block invalidated by avalanche\n\nHeight: 819,346\n\nHash: 00000000000000001d985578bc11edf9bbfee8daad0f39500e3f429c72fcf282\nTimestamp: 1700613264\nMined by unknown, ...863u\nStaking reward winner: ecash:qrpkjsd0fjxd7m332mmlu9px6pwkzaufpcn2u7jcwt\nGuessed reject reason: unknown';
diff --git a/apps/ecash-herald/test/mocks/chronikResponses.js b/apps/ecash-herald/test/mocks/chronikResponses.js
deleted file mode 100644
--- a/apps/ecash-herald/test/mocks/chronikResponses.js
+++ /dev/null
@@ -1,291 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-// @generated
-'use strict';
-
-module.exports = {
-    tx: {
-        '3ce19774ed20535458bb98e864168e6d7d0a68e80f166a7fb00bc9015980ce6d': {
-            txid: '3ce19774ed20535458bb98e864168e6d7d0a68e80f166a7fb00bc9015980ce6d',
-            version: 1,
-            inputs: [
-                {
-                    prevOut: {
-                        txid: '5e04a92c0b6e1493435d34c8f63a8c9905fb6c278662830b35757beec1bd7f12',
-                        outIdx: 1,
-                    },
-                    inputScript:
-                        '4132271505f7bc271e30983bb9f42f634fcc7b83b35efd1465e70fe3192b395099fed6ce943cb21ed742022ceffcc64502f0101e669dc68fbee2dd8d54a8e50e1e4121034474f1431c4401ba1cd22e003c614deaf108695f85b0e7ea357ee3c5c0b3b549',
-                    outputScript:
-                        '76a9148f348f00f7eeb9238b028f5dd14cb9be14395cab88ac',
-                    value: '599417',
-                    sequenceNo: 4294967295,
-                },
-            ],
-            outputs: [
-                {
-                    value: '0',
-                    outputScript:
-                        '6a045357500001020101206350c611819b7e84a2afd9611d33a98de5b3426c33561f516d49147dc1c4106b',
-                },
-                {
-                    value: '546',
-                    outputScript:
-                        '76a91483630e8c91571121a32f57c8c2b58371df7b84e188ac',
-                    spentBy: {
-                        txid: '805ff68b48739b6ec531e3b8de9369579bdac3be8f625127d1fbc145d35dd386',
-                        outIdx: 0,
-                    },
-                },
-                {
-                    value: '598592',
-                    outputScript:
-                        '76a91483630e8c91571121a32f57c8c2b58371df7b84e188ac',
-                    spentBy: {
-                        txid: '805ff68b48739b6ec531e3b8de9369579bdac3be8f625127d1fbc145d35dd386',
-                        outIdx: 1,
-                    },
-                },
-            ],
-            lockTime: 0,
-            block: {
-                height: 798428,
-                hash: '0000000000000000025cd2836f07355eb8d5db6ea16b85db7746da90b1f57b61',
-                timestamp: '1687998840',
-            },
-            timeFirstSeen: '1687998646',
-            size: 271,
-            isCoinbase: false,
-            network: 'XEC',
-        },
-        '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484': {
-            txid: '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
-            version: 2,
-            inputs: [
-                {
-                    prevOut: {
-                        txid: '72eeff7b43dc066164d92e4c3fece47af3a40e89d46e893df1647cd29dd9f1e3',
-                        outIdx: 0,
-                    },
-                    inputScript:
-                        '473044022075166617aa473e86c72f34a5576029eb8766a035b481864ebc75759155efcce00220147e2d7e662123bd728fac700f109a245a0278959f65fc402a1e912e0a5732004121034cdb43b7a1277c4d818dc177aaea4e0bed5d464d240839d5488a278b716facd5',
-                    outputScript:
-                        '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
-                    value: '1000',
-                    sequenceNo: 4294967295,
-                },
-                {
-                    prevOut: {
-                        txid: '46b6f61ca026e243d55668bf304df6a21e1fcb2113943cc6bd1fdeceaae85612',
-                        outIdx: 2,
-                    },
-                    inputScript:
-                        '4830450221009e98db4b91441190bb7e4745b9f249201d0b54c81c0a816af5f3491ffb21a7e902205a4d1347a5a9133c14e4f55319af00f1df836eba6552f30b44640e9373f4cabf4121034cdb43b7a1277c4d818dc177aaea4e0bed5d464d240839d5488a278b716facd5',
-                    outputScript:
-                        '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
-                    value: '750918004',
-                    sequenceNo: 4294967295,
-                },
-            ],
-            outputs: [
-                {
-                    value: '0',
-                    outputScript:
-                        '6a04534c500001010747454e4553495305416c69746105416c6974610a616c6974612e636173684c0001044c00080000befe6f672000',
-                },
-                {
-                    value: '546',
-                    outputScript:
-                        '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
-                    slpToken: {
-                        amount: '210000000000000',
-                        isMintBaton: false,
-                    },
-                    spentBy: {
-                        txid: '2c336374c05f1c8f278d2a1d5f3195a17fe1bc50189ff67c9769a6afcd908ea9',
-                        outIdx: 1,
-                    },
-                },
-                {
-                    value: '750917637',
-                    outputScript:
-                        '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
-                    spentBy: {
-                        txid: 'ca70157d5cf6275e0a36adbc3fabf671e3987f343cb35ec4ee7ed5c8d37b3233',
-                        outIdx: 0,
-                    },
-                },
-            ],
-            lockTime: 0,
-            slpTxData: {
-                slpMeta: {
-                    tokenType: 'FUNGIBLE',
-                    txType: 'GENESIS',
-                    tokenId:
-                        '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
-                },
-                genesisInfo: {
-                    tokenTicker: 'Alita',
-                    tokenName: 'Alita',
-                    tokenDocumentUrl: 'alita.cash',
-                    tokenDocumentHash: '',
-                    decimals: 4,
-                },
-            },
-            block: {
-                height: 756373,
-                hash: '00000000000000000d62f1b66c08f0976bcdec2f08face2892ae4474b50100d9',
-                timestamp: '1662611972',
-            },
-            timeFirstSeen: '1662611666',
-            size: 436,
-            isCoinbase: false,
-            network: 'XEC',
-        },
-        'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a': {
-            txid: 'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
-            version: 2,
-            inputs: [
-                {
-                    prevOut: {
-                        txid: '33938d6bd403e4ffef94de3e9e2ba487f095dcba3544ac8fad4a93808cea0116',
-                        outIdx: 1,
-                    },
-                    inputScript:
-                        '483045022100dad1d237b541b4a4d29197dbb01fa9755c2e17bbafb42855f38442b428f0df6b02205772d3fb00b7a053b07169e1534770c091fce42b9e1d63199f46ff89856b3fc6412102ceb4a6eca1eec20ff8e7780326932e8d8295489628c7f2ec9acf8f37f639235e',
-                    outputScript:
-                        '76a91485bab3680833cd9b3cc60953344fa740a2235bbd88ac',
-                    value: '49998867',
-                    sequenceNo: 4294967295,
-                },
-            ],
-            outputs: [
-                {
-                    value: '0',
-                    outputScript:
-                        '6a04534c500001010747454e4553495303504f571850726f6f666f6657726974696e672e636f6d20546f6b656e2168747470733a2f2f7777772e70726f6f666f6677726974696e672e636f6d2f32364c0001004c000800000000000f4240',
-                },
-                {
-                    value: '546',
-                    outputScript:
-                        '76a91485bab3680833cd9b3cc60953344fa740a2235bbd88ac',
-                    slpToken: {
-                        amount: '1000000',
-                        isMintBaton: false,
-                    },
-                    spentBy: {
-                        txid: '69238630eb9e6a9864bf6970ff5d326800cea41a819feebecfe1a6f0ed651f5c',
-                        outIdx: 1,
-                    },
-                },
-                {
-                    value: '49997563',
-                    outputScript:
-                        '76a91485bab3680833cd9b3cc60953344fa740a2235bbd88ac',
-                    spentBy: {
-                        txid: '3c665488929f852d93a5dfb6e4b4df7bc8f7a25fb4a2480d39e3de7a30437f69',
-                        outIdx: 0,
-                    },
-                },
-            ],
-            lockTime: 0,
-            slpTxData: {
-                slpMeta: {
-                    tokenType: 'FUNGIBLE',
-                    txType: 'GENESIS',
-                    tokenId:
-                        'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
-                },
-                genesisInfo: {
-                    tokenTicker: 'POW',
-                    tokenName: 'ProofofWriting.com Token',
-                    tokenDocumentUrl: 'https://www.proofofwriting.com/26',
-                    tokenDocumentHash: '',
-                    decimals: 0,
-                },
-            },
-            block: {
-                height: 685949,
-                hash: '0000000000000000436e71d5291d2fb067decc838dcb85a99ff6da1d28b89fad',
-                timestamp: '1620712051',
-            },
-            timeFirstSeen: '0',
-            size: 329,
-            isCoinbase: false,
-            network: 'XEC',
-        },
-        '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109': {
-            txid: '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
-            version: 2,
-            inputs: [
-                {
-                    prevOut: {
-                        txid: '0e737a2f6373649341b406334341202a5ddbbdb389c55da40570b641dc23d036',
-                        outIdx: 1,
-                    },
-                    inputScript:
-                        '473044022055444db90f98b462ca29a6f51981da4015623ddc34dc1f575852426ccb785f0402206e786d4056be781ca1720a0a915b040e0a9e8716b8e4d30b0779852c191fdeb3412103771805b54969a9bea4e3eb14a82851c67592156ddb5e52d3d53677d14a40fba6',
-                    outputScript:
-                        '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
-                    value: '6231556',
-                    sequenceNo: 4294967294,
-                },
-            ],
-            outputs: [
-                {
-                    value: '0',
-                    outputScript:
-                        '6a04534c500001010747454e45534953044245415207426561724e69701468747470733a2f2f636173687461622e636f6d2f4c0001004c0008000000000000115c',
-                },
-                {
-                    value: '546',
-                    outputScript:
-                        '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
-                    slpToken: {
-                        amount: '4444',
-                        isMintBaton: false,
-                    },
-                    spentBy: {
-                        txid: '9e7f91826cfd3adf9867c1b3d102594eff4743825fad9883c35d26fb3bdc1693',
-                        outIdx: 1,
-                    },
-                },
-                {
-                    value: '6230555',
-                    outputScript:
-                        '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
-                    spentBy: {
-                        txid: '27a2471afab33d82b9404df12e1fa242488a9439a68e540dcf8f811ef39c11cf',
-                        outIdx: 0,
-                    },
-                },
-            ],
-            lockTime: 0,
-            slpTxData: {
-                slpMeta: {
-                    tokenType: 'FUNGIBLE',
-                    txType: 'GENESIS',
-                    tokenId:
-                        '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
-                },
-                genesisInfo: {
-                    tokenTicker: 'BEAR',
-                    tokenName: 'BearNip',
-                    tokenDocumentUrl: 'https://cashtab.com/',
-                    tokenDocumentHash: '',
-                    decimals: 0,
-                },
-            },
-            block: {
-                height: 782665,
-                hash: '00000000000000001239831f90580c859ec174316e91961cf0e8cde57c0d3acb',
-                timestamp: '1678408305',
-            },
-            timeFirstSeen: '1678408231',
-            size: 299,
-            isCoinbase: false,
-            network: 'XEC',
-        },
-    },
-};
diff --git a/apps/ecash-herald/test/mocks/dailyTxs.js b/apps/ecash-herald/test/mocks/dailyTxs.ts
rename from apps/ecash-herald/test/mocks/dailyTxs.js
rename to apps/ecash-herald/test/mocks/dailyTxs.ts
--- a/apps/ecash-herald/test/mocks/dailyTxs.js
+++ b/apps/ecash-herald/test/mocks/dailyTxs.ts
@@ -2,7 +2,7 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
+import { GenesisInfo, Tx } from 'chronik-client';
 
 /**
  * Instead of mocking 144 blocks of txs, we create an array
@@ -12,7 +12,7 @@
  * However it does not really need to be reviewed
  * Txs to be tested are fetched from chronik.tx and manually added here
  */
-const dailyTxs = [
+export const dailyTxs: Tx[] = [
     // Coinbase tx 1, miner solopool and staker 0cd
     // d86e57cc7caacd61ffa742b13ca4d51177d3e4c8dd619124af79dedf0ac51ea1
     {
@@ -3828,7 +3828,7 @@
     },
 ];
 
-const tokenInfoMap = new Map([
+export const tokenInfoMap: Map<string, GenesisInfo> = new Map([
     [
         '04009a8be347f21a1122964c3226b99c36a9bd755c5a450a53848471a2466103',
         {
@@ -3941,5 +3941,3 @@
         },
     ],
 ]);
-
-module.exports = { dailyTxs, tokenInfoMap };
diff --git a/apps/ecash-herald/test/mocks/memo.js b/apps/ecash-herald/test/mocks/memo.ts
rename from apps/ecash-herald/test/mocks/memo.js
rename to apps/ecash-herald/test/mocks/memo.ts
--- a/apps/ecash-herald/test/mocks/memo.js
+++ b/apps/ecash-herald/test/mocks/memo.ts
@@ -2,9 +2,13 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const opReturn = require('../../constants/op_return');
-module.exports = [
+import opReturn from '../../constants/op_return';
+interface MemoFixture {
+    txid: string;
+    outputScript: string;
+    msg: string;
+}
+const memoFixtures: MemoFixture[] = [
     // 01 - Set name - <name> (1-217 bytes)
     {
         txid: '753e29e81cdea12dc5fa30ca89049ca7d538d4062c4bb1b19ecf2a209a3ac8d9',
@@ -146,3 +150,5 @@
         msg: `Unknown memo action`,
     },
 ];
+
+export default memoFixtures;
diff --git a/apps/ecash-herald/test/mocks/mockChronikCalls.ts b/apps/ecash-herald/test/mocks/mockChronikCalls.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/test/mocks/mockChronikCalls.ts
@@ -0,0 +1,394 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import { Tx, TokenInfo } from 'chronik-client';
+
+// Token genesis txs
+export const bearNipTokenId =
+    '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109';
+export const alitaTokenId =
+    '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484';
+export const powTokenId =
+    'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a';
+
+// SWaP tx
+export const swapTxid =
+    '3ce19774ed20535458bb98e864168e6d7d0a68e80f166a7fb00bc9015980ce6d';
+
+// Map of txid => chronik.tx(txid)
+const mockTxCalls: Map<string, Tx> = new Map();
+mockTxCalls.set(bearNipTokenId, {
+    txid: '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
+    version: 2,
+    inputs: [
+        {
+            prevOut: {
+                txid: '0e737a2f6373649341b406334341202a5ddbbdb389c55da40570b641dc23d036',
+                outIdx: 1,
+            },
+            inputScript:
+                '473044022055444db90f98b462ca29a6f51981da4015623ddc34dc1f575852426ccb785f0402206e786d4056be781ca1720a0a915b040e0a9e8716b8e4d30b0779852c191fdeb3412103771805b54969a9bea4e3eb14a82851c67592156ddb5e52d3d53677d14a40fba6',
+            value: 6231556,
+            sequenceNo: 4294967294,
+            outputScript: '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+        },
+    ],
+    outputs: [
+        {
+            value: 0,
+            outputScript:
+                '6a04534c500001010747454e45534953044245415207426561724e69701468747470733a2f2f636173687461622e636f6d2f4c0001004c0008000000000000115c',
+        },
+        {
+            value: 546,
+            outputScript: '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+            token: {
+                tokenId:
+                    '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
+                tokenType: {
+                    protocol: 'SLP',
+                    type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+                    number: 1,
+                },
+                amount: '4444',
+                isMintBaton: false,
+                entryIdx: 0,
+            },
+            spentBy: {
+                txid: '9e7f91826cfd3adf9867c1b3d102594eff4743825fad9883c35d26fb3bdc1693',
+                outIdx: 1,
+            },
+        },
+        {
+            value: 6230555,
+            outputScript: '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+            spentBy: {
+                txid: '27a2471afab33d82b9404df12e1fa242488a9439a68e540dcf8f811ef39c11cf',
+                outIdx: 0,
+            },
+        },
+    ],
+    lockTime: 0,
+    timeFirstSeen: 0,
+    size: 299,
+    isCoinbase: false,
+    tokenEntries: [
+        {
+            tokenId:
+                '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
+            tokenType: {
+                protocol: 'SLP',
+                type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+                number: 1,
+            },
+            txType: 'GENESIS',
+            isInvalid: false,
+            burnSummary: '',
+            failedColorings: [],
+            actualBurnAmount: '0',
+            intentionalBurn: '0',
+            burnsMintBatons: false,
+        },
+    ],
+    tokenFailedParsings: [],
+    tokenStatus: 'TOKEN_STATUS_NORMAL',
+    block: {
+        height: 782665,
+        hash: '00000000000000001239831f90580c859ec174316e91961cf0e8cde57c0d3acb',
+        timestamp: 1678408305,
+    },
+});
+mockTxCalls.set(alitaTokenId, {
+    txid: '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
+    version: 2,
+    inputs: [
+        {
+            prevOut: {
+                txid: '72eeff7b43dc066164d92e4c3fece47af3a40e89d46e893df1647cd29dd9f1e3',
+                outIdx: 0,
+            },
+            inputScript:
+                '473044022075166617aa473e86c72f34a5576029eb8766a035b481864ebc75759155efcce00220147e2d7e662123bd728fac700f109a245a0278959f65fc402a1e912e0a5732004121034cdb43b7a1277c4d818dc177aaea4e0bed5d464d240839d5488a278b716facd5',
+            value: 1000,
+            sequenceNo: 4294967295,
+            outputScript: '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
+        },
+        {
+            prevOut: {
+                txid: '46b6f61ca026e243d55668bf304df6a21e1fcb2113943cc6bd1fdeceaae85612',
+                outIdx: 2,
+            },
+            inputScript:
+                '4830450221009e98db4b91441190bb7e4745b9f249201d0b54c81c0a816af5f3491ffb21a7e902205a4d1347a5a9133c14e4f55319af00f1df836eba6552f30b44640e9373f4cabf4121034cdb43b7a1277c4d818dc177aaea4e0bed5d464d240839d5488a278b716facd5',
+            value: 750918004,
+            sequenceNo: 4294967295,
+            outputScript: '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
+        },
+    ],
+    outputs: [
+        {
+            value: 0,
+            outputScript:
+                '6a04534c500001010747454e4553495305416c69746105416c6974610a616c6974612e636173684c0001044c00080000befe6f672000',
+        },
+        {
+            value: 546,
+            outputScript: '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
+            token: {
+                tokenId:
+                    '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
+                tokenType: {
+                    protocol: 'SLP',
+                    type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+                    number: 1,
+                },
+                amount: '210000000000000',
+                isMintBaton: false,
+                entryIdx: 0,
+            },
+            spentBy: {
+                txid: '2c336374c05f1c8f278d2a1d5f3195a17fe1bc50189ff67c9769a6afcd908ea9',
+                outIdx: 1,
+            },
+        },
+        {
+            value: 750917637,
+            outputScript: '76a914f5f740bc76e56b77bcab8b4d7f888167f416fc6888ac',
+            spentBy: {
+                txid: 'ca70157d5cf6275e0a36adbc3fabf671e3987f343cb35ec4ee7ed5c8d37b3233',
+                outIdx: 0,
+            },
+        },
+    ],
+    lockTime: 0,
+    timeFirstSeen: 0,
+    size: 436,
+    isCoinbase: false,
+    tokenEntries: [
+        {
+            tokenId:
+                '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
+            tokenType: {
+                protocol: 'SLP',
+                type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+                number: 1,
+            },
+            txType: 'GENESIS',
+            isInvalid: false,
+            burnSummary: '',
+            failedColorings: [],
+            actualBurnAmount: '0',
+            intentionalBurn: '0',
+            burnsMintBatons: false,
+        },
+    ],
+    tokenFailedParsings: [],
+    tokenStatus: 'TOKEN_STATUS_NORMAL',
+    block: {
+        height: 756373,
+        hash: '00000000000000000d62f1b66c08f0976bcdec2f08face2892ae4474b50100d9',
+        timestamp: 1662611972,
+    },
+});
+mockTxCalls.set(powTokenId, {
+    txid: 'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
+    version: 2,
+    inputs: [
+        {
+            prevOut: {
+                txid: '33938d6bd403e4ffef94de3e9e2ba487f095dcba3544ac8fad4a93808cea0116',
+                outIdx: 1,
+            },
+            inputScript:
+                '483045022100dad1d237b541b4a4d29197dbb01fa9755c2e17bbafb42855f38442b428f0df6b02205772d3fb00b7a053b07169e1534770c091fce42b9e1d63199f46ff89856b3fc6412102ceb4a6eca1eec20ff8e7780326932e8d8295489628c7f2ec9acf8f37f639235e',
+            value: 49998867,
+            sequenceNo: 4294967295,
+            outputScript: '76a91485bab3680833cd9b3cc60953344fa740a2235bbd88ac',
+        },
+    ],
+    outputs: [
+        {
+            value: 0,
+            outputScript:
+                '6a04534c500001010747454e4553495303504f571850726f6f666f6657726974696e672e636f6d20546f6b656e2168747470733a2f2f7777772e70726f6f666f6677726974696e672e636f6d2f32364c0001004c000800000000000f4240',
+        },
+        {
+            value: 546,
+            outputScript: '76a91485bab3680833cd9b3cc60953344fa740a2235bbd88ac',
+            token: {
+                tokenId:
+                    'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
+                tokenType: {
+                    protocol: 'SLP',
+                    type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+                    number: 1,
+                },
+                amount: '1000000',
+                isMintBaton: false,
+                entryIdx: 0,
+            },
+            spentBy: {
+                txid: '69238630eb9e6a9864bf6970ff5d326800cea41a819feebecfe1a6f0ed651f5c',
+                outIdx: 1,
+            },
+        },
+        {
+            value: 49997563,
+            outputScript: '76a91485bab3680833cd9b3cc60953344fa740a2235bbd88ac',
+            spentBy: {
+                txid: '3c665488929f852d93a5dfb6e4b4df7bc8f7a25fb4a2480d39e3de7a30437f69',
+                outIdx: 0,
+            },
+        },
+    ],
+    lockTime: 0,
+    timeFirstSeen: 0,
+    size: 329,
+    isCoinbase: false,
+    tokenEntries: [
+        {
+            tokenId:
+                'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
+            tokenType: {
+                protocol: 'SLP',
+                type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+                number: 1,
+            },
+            txType: 'GENESIS',
+            isInvalid: false,
+            burnSummary: '',
+            failedColorings: [],
+            actualBurnAmount: '0',
+            intentionalBurn: '0',
+            burnsMintBatons: false,
+        },
+    ],
+    tokenFailedParsings: [],
+    tokenStatus: 'TOKEN_STATUS_NORMAL',
+    block: {
+        height: 685949,
+        hash: '0000000000000000436e71d5291d2fb067decc838dcb85a99ff6da1d28b89fad',
+        timestamp: 1620712051,
+    },
+});
+mockTxCalls.set(swapTxid, {
+    txid: '3ce19774ed20535458bb98e864168e6d7d0a68e80f166a7fb00bc9015980ce6d',
+    version: 1,
+    inputs: [
+        {
+            prevOut: {
+                txid: '5e04a92c0b6e1493435d34c8f63a8c9905fb6c278662830b35757beec1bd7f12',
+                outIdx: 1,
+            },
+            inputScript:
+                '4132271505f7bc271e30983bb9f42f634fcc7b83b35efd1465e70fe3192b395099fed6ce943cb21ed742022ceffcc64502f0101e669dc68fbee2dd8d54a8e50e1e4121034474f1431c4401ba1cd22e003c614deaf108695f85b0e7ea357ee3c5c0b3b549',
+            value: 599417,
+            sequenceNo: 4294967295,
+            outputScript: '76a9148f348f00f7eeb9238b028f5dd14cb9be14395cab88ac',
+        },
+    ],
+    outputs: [
+        {
+            value: 0,
+            outputScript:
+                '6a045357500001020101206350c611819b7e84a2afd9611d33a98de5b3426c33561f516d49147dc1c4106b',
+        },
+        {
+            value: 546,
+            outputScript: '76a91483630e8c91571121a32f57c8c2b58371df7b84e188ac',
+            spentBy: {
+                txid: '805ff68b48739b6ec531e3b8de9369579bdac3be8f625127d1fbc145d35dd386',
+                outIdx: 0,
+            },
+        },
+        {
+            value: 598592,
+            outputScript: '76a91483630e8c91571121a32f57c8c2b58371df7b84e188ac',
+            spentBy: {
+                txid: '805ff68b48739b6ec531e3b8de9369579bdac3be8f625127d1fbc145d35dd386',
+                outIdx: 1,
+            },
+        },
+    ],
+    lockTime: 0,
+    timeFirstSeen: 0,
+    size: 271,
+    isCoinbase: false,
+    tokenEntries: [],
+    tokenFailedParsings: [],
+    tokenStatus: 'TOKEN_STATUS_NON_TOKEN',
+    block: {
+        height: 798428,
+        hash: '0000000000000000025cd2836f07355eb8d5db6ea16b85db7746da90b1f57b61',
+        timestamp: 1687998840,
+    },
+});
+
+// Map of txid => chronik.tx(txid)
+const mockTokenCalls: Map<string, TokenInfo> = new Map();
+mockTokenCalls.set(bearNipTokenId, {
+    tokenId: '3fee3384150b030490b7bee095a63900f66a45f2d8e3002ae2cf17ce3ef4d109',
+    tokenType: {
+        protocol: 'SLP',
+        type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+        number: 1,
+    },
+    timeFirstSeen: 0,
+    genesisInfo: {
+        tokenTicker: 'BEAR',
+        tokenName: 'BearNip',
+        url: 'https://cashtab.com/',
+        decimals: 0,
+        hash: '',
+    },
+    block: {
+        height: 782665,
+        hash: '00000000000000001239831f90580c859ec174316e91961cf0e8cde57c0d3acb',
+        timestamp: 1678408305,
+    },
+});
+mockTokenCalls.set(alitaTokenId, {
+    tokenId: '54dc2ecd5251f8dfda4c4f15ce05272116b01326076240e2b9cc0104d33b1484',
+    tokenType: {
+        protocol: 'SLP',
+        type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+        number: 1,
+    },
+    timeFirstSeen: 0,
+    genesisInfo: {
+        tokenTicker: 'Alita',
+        tokenName: 'Alita',
+        url: 'alita.cash',
+        decimals: 4,
+        hash: '',
+    },
+    block: {
+        height: 756373,
+        hash: '00000000000000000d62f1b66c08f0976bcdec2f08face2892ae4474b50100d9',
+        timestamp: 1662611972,
+    },
+});
+mockTokenCalls.set(powTokenId, {
+    tokenId: 'f36e1b3d9a2aaf74f132fef3834e9743b945a667a4204e761b85f2e7b65fd41a',
+    tokenType: {
+        protocol: 'SLP',
+        type: 'SLP_TOKEN_TYPE_FUNGIBLE',
+        number: 1,
+    },
+    timeFirstSeen: 0,
+    genesisInfo: {
+        tokenTicker: 'POW',
+        tokenName: 'ProofofWriting.com Token',
+        url: 'https://www.proofofwriting.com/26',
+        decimals: 0,
+        hash: '',
+    },
+    block: {
+        height: 685949,
+        hash: '0000000000000000436e71d5291d2fb067decc838dcb85a99ff6da1d28b89fad',
+        timestamp: 1620712051,
+    },
+});
+
+export { mockTxCalls, mockTokenCalls };
diff --git a/apps/ecash-herald/test/mocks/telegramBotMock.js b/apps/ecash-herald/test/mocks/telegramBotMock.js
deleted file mode 100644
--- a/apps/ecash-herald/test/mocks/telegramBotMock.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Copyright (c) 2023 The Bitcoin developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-'use strict';
-
-/* Mock node-telegram-bot-api TelegramBot instance
- * Supports sendMessage function
- */
-module.exports = {
-    MockTelegramBot: class {
-        constructor() {
-            // Use self since it is not a reserved term in js
-            // Can access self from inside a method and still get the class
-            const self = this;
-            self.messageSent = false;
-            self.errors = {};
-            self.sendMessage = function (channelId, msg, options) {
-                if (!self.errors.sendMessage) {
-                    self.messageSent = true;
-                    return { success: true, channelId, msg, options };
-                }
-                throw new Error(self.errors.sendMessage);
-            };
-            self.setExpectedError = function (method, error) {
-                self.errors[method] = error;
-            };
-        }
-    },
-    mockChannelId: '-1001999999999',
-};
diff --git a/apps/ecash-herald/test/mocks/telegramBotMock.ts b/apps/ecash-herald/test/mocks/telegramBotMock.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/test/mocks/telegramBotMock.ts
@@ -0,0 +1,47 @@
+// Copyright (c) 2023 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+/* Mock node-telegram-bot-api TelegramBot instance
+ * Supports sendMessage function
+ */
+import { SendMessageOptions } from 'node-telegram-bot-api';
+export const mockChannelId = '-1001999999999';
+
+interface SendMessageResponse {
+    success: boolean;
+    channelId: string;
+    msg: string;
+    options: SendMessageOptions;
+}
+
+export interface MockTelegramBot {
+    messageSent: boolean;
+    errors: { [key: string]: string | undefined };
+    sendMessage: (
+        channelId: string,
+        msg: string,
+        options: SendMessageOptions,
+    ) => SendMessageResponse;
+    setExpectedError: (method: string, error: string) => void;
+}
+
+export class MockTelegramBot implements MockTelegramBot {
+    constructor() {
+        // Use self since it is not a reserved term in js
+        // Can access self from inside a method and still get the class
+        const self = this;
+        self.messageSent = false;
+        self.errors = {};
+        self.sendMessage = function (channelId, msg, options) {
+            if (!self.errors.sendMessage) {
+                self.messageSent = true;
+                return { success: true, channelId, msg, options };
+            }
+            throw new Error(self.errors.sendMessage);
+        };
+        self.setExpectedError = function (method, error) {
+            self.errors[method] = error;
+        };
+    }
+}
diff --git a/apps/ecash-herald/test/mocks/telegramMsgs.js b/apps/ecash-herald/test/mocks/telegramMsgs.ts
rename from apps/ecash-herald/test/mocks/telegramMsgs.js
rename to apps/ecash-herald/test/mocks/telegramMsgs.ts
--- a/apps/ecash-herald/test/mocks/telegramMsgs.js
+++ b/apps/ecash-herald/test/mocks/telegramMsgs.ts
@@ -1,10 +1,25 @@
 // Copyright (c) 2023 The Bitcoin developers
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
-// @generated
 
-'use strict';
-module.exports = {
+import { SendMessageOptions } from 'node-telegram-bot-api';
+
+interface MsgSuccessMock {
+    channelId: string;
+    msg: string;
+    options: SendMessageOptions;
+    success: boolean;
+}
+interface tgMsgMocks {
+    overflowMsg: string[];
+    overflowMsgSplit: string[];
+    overflowMsgTwo: string[];
+    overflowMsgSplitTwo: string[];
+    overflowMsgSuccess: MsgSuccessMock[];
+    nonOverflowMsg: string[];
+    nonOverflowMsgSuccess: MsgSuccessMock[];
+}
+const tgMsgMocks = {
     overflowMsg: [
         '<a href="https://explorer.e.cash/block/0000000000000000260ee4c3b4f4ddde127bc0105d685c0ef31775b612627222">700722</a> | 97 txs | unknown',
         '1 XEC = $0.00002862',
@@ -233,3 +248,5 @@
         },
     ],
 };
+
+export default tgMsgMocks;
diff --git a/apps/ecash-herald/test/mocks/templates.js b/apps/ecash-herald/test/mocks/templates.ts
rename from apps/ecash-herald/test/mocks/templates.js
rename to apps/ecash-herald/test/mocks/templates.ts
--- a/apps/ecash-herald/test/mocks/templates.js
+++ b/apps/ecash-herald/test/mocks/templates.ts
@@ -2,8 +2,20 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-module.exports = {
+import { FiatCode } from '../../config';
+import { CoinGeckoPrice } from '../../src/utils';
+
+interface TgStringFixture {
+    dangerous: string;
+    safe: string;
+    noChangeExpected: string;
+}
+interface Templates {
+    telegramHtmlStrings: TgStringFixture;
+    addressPreviews: { address: string; preview: string; sliceSize: number }[];
+    mockCoingeckoPrices: CoinGeckoPrice[];
+}
+const templates = {
     telegramHtmlStrings: {
         dangerous: '<b>Try to hack the format</b> ${true && <i>yes</i>}',
         safe: '&lt;b&gt;Try to hack the format&lt;/b&gt; ${true &amp;&amp; &lt;i&gt;yes&lt;/i&gt;}',
@@ -69,19 +81,21 @@
     ],
     mockCoingeckoPrices: [
         {
-            fiat: 'usd',
+            fiat: 'usd' as FiatCode,
             price: 0.00003,
             ticker: 'XEC',
         },
         {
-            fiat: 'usd',
+            fiat: 'usd' as FiatCode,
             price: 28044.64857505,
             ticker: 'BTC',
         },
         {
-            fiat: 'usd',
+            fiat: 'usd' as FiatCode,
             price: 1900.73166438,
             ticker: 'ETH',
         },
     ],
 };
+
+export default templates;
diff --git a/apps/ecash-herald/test/parse.test.js b/apps/ecash-herald/test/parse.test.ts
rename from apps/ecash-herald/test/parse.test.js
rename to apps/ecash-herald/test/parse.test.ts
--- a/apps/ecash-herald/test/parse.test.js
+++ b/apps/ecash-herald/test/parse.test.ts
@@ -2,23 +2,21 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const assert = require('assert');
-const opReturn = require('../constants/op_return');
-const unrevivedBlock = require('./mocks/block');
-const minersJson = require('../constants/miners');
-const minerTestFixtures = require('./fixtures/miners');
-const stakerTestFixtures = require('./fixtures/stakers');
-const invalidatedBlocksTestFixtures = require('./fixtures/invalidatedBlocks');
-const { jsonReviver } = require('../src/utils');
-const block = JSON.parse(JSON.stringify(unrevivedBlock), jsonReviver);
-const miners = JSON.parse(JSON.stringify(minersJson), jsonReviver);
-const memoOutputScripts = require('./mocks/memo');
-const { consumeNextPush } = require('ecash-script');
-const { MockChronikClient } = require('../../../modules/mock-chronik-client');
-const { caching } = require('cache-manager');
-
-const {
+import assert from 'assert';
+import opReturn from '../constants/op_return';
+import unrevivedBlock from './mocks/block';
+import minersJson, { KnownMiners } from '../constants/miners';
+import minerTestFixtures from './fixtures/miners';
+import stakerTestFixtures from './fixtures/stakers';
+import invalidatedBlocksTestFixtures from './fixtures/invalidatedBlocks';
+import { jsonReviver } from '../src/utils';
+import memoFixtures from './mocks/memo';
+import { consumeNextPush } from 'ecash-script';
+import { MockChronikClient } from '../../../modules/mock-chronik-client';
+import { TxOutput } from 'chronik-client';
+import { caching } from 'cache-manager';
+import { StoredMock } from '../src/events';
+import {
     parseBlockTxs,
     getStakerFromCoinbaseTx,
     getMinerFromCoinbaseTx,
@@ -32,7 +30,10 @@
     parseSlpTwo,
     guessRejectReason,
     summarizeTxHistory,
-} = require('../src/parse');
+} from '../src/parse';
+import appTxSamples from './mocks/appTxSamples';
+import { dailyTxs, tokenInfoMap } from './mocks/dailyTxs';
+
 const {
     swaps,
     airdrops,
@@ -44,8 +45,13 @@
     payButtonTxs,
     paywallTxs,
     authenticationTxs,
-} = require('./mocks/appTxSamples');
-const { dailyTxs, tokenInfoMap } = require('./mocks/dailyTxs');
+} = appTxSamples;
+
+const block: StoredMock = JSON.parse(
+    JSON.stringify(unrevivedBlock),
+    jsonReviver,
+);
+const miners: KnownMiners = JSON.parse(JSON.stringify(minersJson), jsonReviver);
 
 describe('parse.js functions', function () {
     it('Parses the master test block', function () {
@@ -278,7 +284,7 @@
         );
     });
     it(`parseMemoOutputScript correctly parses all tested memo actions in memo.js`, function () {
-        memoOutputScripts.map(memoTestObj => {
+        memoFixtures.map(memoTestObj => {
             const { outputScript, msg } = memoTestObj;
             // Get array of pushes
             let stack = { remainingHex: outputScript.slice(2) };
@@ -299,7 +305,7 @@
             assert.deepEqual(
                 getStakerFromCoinbaseTx(
                     coinbaseTx.block.height,
-                    coinbaseTx.outputs,
+                    coinbaseTx.outputs as TxOutput[],
                 ),
                 staker,
             );
@@ -311,7 +317,9 @@
                 minerTestFixtures[i];
             // Minimally mock the coinbase tx
             const inputScript = coinbaseHex;
-            const outputs = [{ outputScript: payoutOutputScript }];
+            const outputs = [
+                { outputScript: payoutOutputScript },
+            ] as TxOutput[];
 
             assert.strictEqual(
                 getMinerFromCoinbaseTx(inputScript, outputs, miners),
diff --git a/apps/ecash-herald/test/telegram.test.js b/apps/ecash-herald/test/telegram.test.ts
rename from apps/ecash-herald/test/telegram.test.js
rename to apps/ecash-herald/test/telegram.test.ts
--- a/apps/ecash-herald/test/telegram.test.js
+++ b/apps/ecash-herald/test/telegram.test.ts
@@ -2,14 +2,16 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const assert = require('assert');
-const {
+import assert from 'assert';
+import {
     prepareStringForTelegramHTML,
     splitOverflowTgMsg,
     sendBlockSummary,
-} = require('../src/telegram');
-const { telegramHtmlStrings } = require('./mocks/templates');
+} from '../src/telegram';
+import templates from './mocks/templates';
+import tgMsgMocks from './mocks/telegramMsgs';
+import block from './mocks/block';
+import { MockTelegramBot, mockChannelId } from './mocks/telegramBotMock';
 const {
     overflowMsg,
     overflowMsgTwo,
@@ -18,9 +20,9 @@
     overflowMsgSuccess,
     nonOverflowMsg,
     nonOverflowMsgSuccess,
-} = require('./mocks/telegramMsgs');
-const block = require('./mocks/block');
-const { MockTelegramBot, mockChannelId } = require('./mocks/telegramBotMock');
+} = tgMsgMocks;
+
+const { telegramHtmlStrings } = templates;
 
 describe('ecash-herald telegram.js functions', function () {
     it(`prepareStringForTelegramHTML replaces '<', '>', and '&' per specifications`, function () {
diff --git a/apps/ecash-herald/test/utils.test.js b/apps/ecash-herald/test/utils.test.ts
rename from apps/ecash-herald/test/utils.test.js
rename to apps/ecash-herald/test/utils.test.ts
--- a/apps/ecash-herald/test/utils.test.js
+++ b/apps/ecash-herald/test/utils.test.ts
@@ -2,13 +2,12 @@
 // Distributed under the MIT software license, see the accompanying
 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
 
-'use strict';
-const assert = require('assert');
-const BigNumber = require('bignumber.js');
-const axios = require('axios');
-const MockAdapter = require('axios-mock-adapter');
-const config = require('../config');
-const {
+import assert from 'assert';
+import BigNumber from 'bignumber.js';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import config, { FiatCode, HeraldPriceApi } from '../config';
+import {
     returnAddressPreview,
     getCoingeckoPrices,
     formatPrice,
@@ -20,8 +19,9 @@
     getEmojiFromBalanceSats,
     bigNumberAmountToLocaleString,
     containsOnlyPrintableAscii,
-} = require('../src/utils');
-const { addressPreviews, mockCoingeckoPrices } = require('./mocks/templates');
+} from '../src/utils';
+import templates from './mocks/templates';
+const { addressPreviews, mockCoingeckoPrices } = templates;
 
 describe('ecash-telegram-bot utils.js functions', function () {
     it('returnAddressPreview converts a valid ecash: address into an abbreviated preview at various slice sizes', function () {
@@ -70,7 +70,7 @@
         });
     });
     it('getCoingeckoPrices returns object of expected shape for API call of custom config', async function () {
-        const apiConfig = {
+        const apiConfig: HeraldPriceApi = {
             apiBase: 'https://api.coingecko.com/api/v3/simple/price',
             cryptos: [
                 { coingeckoSlug: 'ecash', ticker: 'XEC' },
@@ -96,17 +96,17 @@
         // Expected value will include ticker information
         const expectedCoingeckoPrices = [
             {
-                fiat: 'eur',
+                fiat: 'eur' as FiatCode,
                 price: 0.00003113,
                 ticker: 'XEC',
             },
             {
-                fiat: 'eur',
+                fiat: 'eur' as FiatCode,
                 price: 107.64857505,
                 ticker: 'XMR',
             },
             {
-                fiat: 'eur',
+                fiat: 'eur' as FiatCode,
                 price: 22.73166438,
                 ticker: 'SOL',
             },
@@ -172,7 +172,10 @@
         assert.strictEqual(formatPrice(100000.999923422, 'jpy'), `¥100,001`);
     });
     it('formatPrice omits a currency symbol if it cannot find it', function () {
-        assert.strictEqual(formatPrice(100000.999923422, 'cad'), `100,001`);
+        assert.strictEqual(
+            formatPrice(100000.999923422, 'cad' as FiatCode),
+            `100,001`,
+        );
     });
     it('formatXecAmount returns a string with 2 decimal places if XEC amount < 10', function () {
         assert.strictEqual(formatXecAmount(9.99), `9.99 XEC`);
@@ -282,7 +285,7 @@
     it('satsToFormattedValue returns a formatted fiat amount if £1M < total fiat value < £1B', function () {
         const gbpPrices = [
             {
-                fiat: 'gbp',
+                fiat: 'gbp' as FiatCode,
                 price: 0.00003,
                 ticker: 'XEC',
             },
@@ -371,9 +374,13 @@
         const { whaleSats, emojis } = config;
         const names = Object.keys(whaleSats);
         for (let i = 0; i < names.length; i += 1) {
+            // Really bad types in the config as app was written without ts
+            // TODO fixme
+            // @ts-ignore
             const balanceSats = whaleSats[names[i]];
             assert.strictEqual(
                 getEmojiFromBalanceSats(balanceSats),
+                // @ts-ignore
                 emojis[names[i]],
             );
         }
diff --git a/apps/ecash-herald/tsconfig.json b/apps/ecash-herald/tsconfig.json
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/tsconfig.json
@@ -0,0 +1,14 @@
+{
+    "compilerOptions": {
+        "target": "es2020",
+        "module": "commonjs",
+        "outDir": "./dist",
+        "esModuleInterop": true,
+        "forceConsistentCasingInFileNames": true,
+        "strict": true,
+        "alwaysStrict": true,
+        "skipLibCheck": true
+    },
+    "include": ["**/*.ts"],
+    "exclude": ["node_modules/"]
+}
diff --git a/ecash-herald.Dockerfile b/ecash-herald.Dockerfile
--- a/ecash-herald.Dockerfile
+++ b/ecash-herald.Dockerfile
@@ -78,6 +78,11 @@
 RUN npm ci
 RUN npm run build
 
+# mock-chronik-client
+WORKDIR /app/modules/mock-chronik-client
+COPY modules/mock-chronik-client/ .
+RUN npm ci
+
 # Now that local dependencies are ready, build ecash-herald
 WORKDIR /app/apps/ecash-herald
 
@@ -90,5 +95,8 @@
 # Copy the rest of the project files
 COPY apps/ecash-herald/ .
 
-# ecash-herald runs with "node index.js"
-CMD [ "node", "index.js" ]
+# Compile typescript. Outputs to dist/ dir
+RUN npm run build
+
+# ecash-herald runs with "node dist/index.js"
+CMD [ "node", "dist/index.js" ]
diff --git a/modules/ecash-script/index.d.ts b/modules/ecash-script/index.d.ts
new file mode 100644
--- /dev/null
+++ b/modules/ecash-script/index.d.ts
@@ -0,0 +1 @@
+declare module 'ecash-script';