Page MenuHomePhabricator

D17060.id50607.diff
No OneTemporary

D17060.id50607.diff

This file is larger than 256 KB, so syntax highlighting was skipped.
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,4 +1,4 @@
-// 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.
@@ -22,13 +22,27 @@
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],
+ // Add TypeScript specific rules or overrides here if needed
+ // For example, if you want to enforce semicolons in TypeScript files:
+ '@typescript-eslint/semi': ['error', 'always'],
+ // Disable rules overridden by TypeScript
+ 'no-use-before-define': 'off',
+ '@typescript-eslint/no-use-before-define': ['error'],
},
};
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/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,20 +2,20 @@
// 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 cron 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(
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
@@ -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
@@ -6,6 +6,7 @@
"scripts": {
"test": "mocha",
"coverage": "nyc mocha",
+ "build": "tsc",
"junit": "mocha test --reporter mocha-junit-reporter",
"generateMock": "node scripts/generateMock",
"getCoingeckoPrices": "node scripts/getCoingeckoPrices",
@@ -36,11 +37,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/
@@ -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(
@@ -47,7 +52,8 @@
);
for (let i in coingeckoPrices) {
- const { fiat, price, ticker } = coingeckoPrices[i];
+ // @ts-ignore
+ const { fiat, price, ticker }: any = coingeckoPrices[i] as any;
const formattedPrice = formatPrice(price, fiat);
console.log(`1 ${ticker} = ${formattedPrice} ${fiat.toUpperCase()}`);
}
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/secrets.ts b/apps/ecash-herald/secrets.ts
new file mode 100644
--- /dev/null
+++ b/apps/ecash-herald/secrets.ts
@@ -0,0 +1,33 @@
+// 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.
+
+interface TelegramSettings {
+ botId: string;
+ channelId: string;
+ dailyChannelId: string;
+}
+
+interface Secrets {
+ dev: { telegram: TelegramSettings };
+ prod: { telegram: TelegramSettings };
+}
+
+const secrets: Secrets = {
+ dev: {
+ telegram: {
+ botId: '6055542670:AAFkt_Bjy-LXQV-fVmBy3-DnF0q7YJKUeOI',
+ channelId: '-1001957593106',
+ dailyChannelId: '@eCashEveryDay',
+ },
+ },
+ prod: {
+ telegram: {
+ botId: '6055542670:AAFkt_Bjy-LXQV-fVmBy3-DnF0q7YJKUeOI',
+ channelId: '-1001957593106',
+ dailyChannelId: '@eCashEveryDay',
+ },
+ },
+};
+
+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,3702 @@
+// 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) {
+ console.log(`returning`);
+ 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.backup.ts
rename from apps/ecash-herald/test/mocks/block.js
rename to apps/ecash-herald/test/mocks/block.backup.ts
--- a/apps/ecash-herald/test/mocks/block.js
+++ b/apps/ecash-herald/test/mocks/block.backup.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',
@@ -6103,3 +6101,5 @@
'📦<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/block.js b/apps/ecash-herald/test/mocks/block.legacy.js
copy from apps/ecash-herald/test/mocks/block.js
copy to apps/ecash-herald/test/mocks/block.legacy.js
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/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';

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 26, 11:53 (2 h, 35 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5573470
Default Alt Text
D17060.id50607.diff (611 KB)

Event Timeline