Page MenuHomePhabricator

No OneTemporary

diff --git a/apps/token-server/index.ts b/apps/token-server/index.ts
index ced5c459b..1ed8d9e47 100644
--- a/apps/token-server/index.ts
+++ b/apps/token-server/index.ts
@@ -1,103 +1,104 @@
// 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.
import config from './config';
import secrets from './secrets';
import 'dotenv/config';
import { startExpressServer } from './src/routes';
import { ChronikClient } from 'chronik-client';
import { initializeTelegramBot } from './src/telegram';
import fs from 'fs';
import { Ecc, initWasm } from 'ecash-lib';
import { rateLimit } from 'express-rate-limit';
import { MongoClient } from 'mongodb';
import { initializeDb } from './src/db';
// Connect to available in-node chronik servers
const chronik = new ChronikClient(config.chronikUrls);
// Connect to database
// Connection URL (default)
const MONGODB_URL = `mongodb://${secrets.prod.db.username}:${secrets.prod.db.password}@${secrets.prod.db.containerName}:${secrets.prod.db.port}`;
const client = new MongoClient(MONGODB_URL);
// Check if database exists
// Initialize websocket connection and log incoming blocks
initWasm().then(
() => {
initializeDb(client).then(
db => {
const ecc = new Ecc();
// Initialize telegramBot
const telegramBot = initializeTelegramBot(
secrets.prod.botId,
secrets.prod.approvedMods,
fs,
+ db,
);
// Start the express app to expose API endpoints
const server = startExpressServer(
config.port,
db,
chronik,
telegramBot,
fs,
ecc,
rateLimit(config.limiter),
rateLimit(config.tokenLimiter),
);
console.log(`Express server started on port ${config.port}`);
// Gracefully shut down on app termination
process.on('SIGTERM', () => {
// kill <pid> from terminal
server.close();
console.log('token-server shut down by SIGTERM');
// Shut down the telegram bot
telegramBot.stopPolling();
// Shut down the database
client.close().then(() => {
console.log('MongoDB connection closed');
// Shut down token-server in non-error condition
process.exit(0);
});
});
process.on('SIGINT', () => {
// ctrl + c in nodejs
server.close();
console.log('token-server shut down by ctrl+c');
// Shut down the telegram bot
telegramBot.stopPolling();
// Shut down the database
client.close().then(() => {
console.log('MongoDB connection closed');
// Shut down token-server in non-error condition
process.exit(0);
});
});
},
err => {
console.log(`Error initializing database`, err);
// Shut down the database
client.close().then(() => {
console.log('MongoDB connection closed');
// Shut down token-server in error condition
process.exit(1);
});
},
);
},
err => {
console.log(`Error initializing webassembly in token-server`, err);
// Shut down the database
client.close().then(() => {
console.log('MongoDB connection closed');
// Shut down token-server in error condition
process.exit(1);
});
},
);
diff --git a/apps/token-server/src/db.ts b/apps/token-server/src/db.ts
index 15c4b2902..2dba440ed 100644
--- a/apps/token-server/src/db.ts
+++ b/apps/token-server/src/db.ts
@@ -1,176 +1,217 @@
// 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.
-import { MongoClient, Db, Collection, CollectionInfo } from 'mongodb';
+import {
+ MongoClient,
+ Db,
+ Collection,
+ CollectionInfo,
+ InsertOneResult,
+ DeleteResult,
+} from 'mongodb';
import config from '../config';
interface BlacklistEntry {
/** tokenId of blacklisted token */
tokenId: string;
/** A short explanation of why this token was blacklisted, e.g. "impersonating tether" */
reason: string;
/**
* When this token was added to the blacklist
* We use number instead of Date as the API returns JSON
*/
timestamp: number;
/** string describing who added this token to the blacklist */
addedBy: string;
}
const initialBlacklistTokens = [
{
tokenId:
'09c53c9a9fe0df2cb729dd6f99f2b836c59b842d6652becd85658e277caab611',
reason: 'Impersonates Blazer (site that runs poker tournaments)',
},
{
tokenId:
'9c662233f8553e72ab3848a37d72fbc3f894611aae43033cde707213a537bba0',
reason: 'Impersonates BUX stablecoin',
},
{
tokenId:
'6dcb149e77a8f86a85d2fb8505dadb194994a922102fcea6309f2818de9ee173',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'059308a0d6ef0443d8bd014ac85f830d98780b1ce53bc2326680ed27e99803f6',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'2a328dbe125bd0ef8d199b2b4f20ce84bb36a7c0d12246668163a6077d4f494b',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'3387978c85f382632ecb5cdc23c4912c4c22688790d9264f84c3c1351c049719',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'07da70e787181ac67a34f9292b4e13a93cd081e4ca540a8ddafe4cc86ee26e2d',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'4e56e9bedfb654560eb1917b2e2fa40473cf26a8a9a0f84e0b0e91a9cce1df65',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'2a33476bcd30bfbc5e57fb33da26f641020a53c925db7394e6d3b8eecf82e2ec',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'b69dcc90c72e852e1dc712704cb376e588cee6266a51e647c61a724c00625cc8',
reason: 'Impersonating a USD stablecoin',
},
{
tokenId:
'7c14895521c158798478a64d146f67f22e1c8c5b962422ed47636fda71d82f1d',
reason: 'Impersonating Meta and attempting to use their logo',
},
{
tokenId:
'6f231d49fefd938a9a6b4e6b93d14c7127e11bd5621056eb9c6528164b9d7ce0',
reason: 'Impersonating Meta and attempting to use their logo',
},
{
tokenId:
'a6a16ac38d37e35c9f9eb81e9014827cef9da105a94607ec16a2c6e76224d098',
reason: 'Impersonating corporate brand using their logo',
},
{
tokenId:
'db2e95abe66f6b1f21a860a177b7a73565182185a99b6043b5183f59df7ecfbf',
reason: 'Impersonating corporate brand using their logo',
},
{
tokenId:
'4c008a1cd5002063d2942daed16ff0e118bc3e41c7c0a4155ac096ee5a389c21',
reason: 'Impersonating RAIPAY',
},
];
const initialBlacklist = initialBlacklistTokens.map(item => ({
...item,
// When added to this file
timestamp: Math.round(new Date(1730090292122).getTime() / 1000),
addedBy: 'Initial Setup',
}));
export { initialBlacklist };
export const initializeDb = async (
client: MongoClient,
blacklist = initialBlacklist,
): Promise<Db> => {
await client.connect();
console.log('Successfully connected to mongod database');
const db: Db = client.db(config.db.name);
const collections: CollectionInfo[] = await db.listCollections().toArray();
const blacklistCollectionName = config.db.collections.blacklist.name;
// Check if the collection exists in the list
const blacklistExists: boolean = collections.some(
collection => collection.name === blacklistCollectionName,
);
if (!blacklistExists) {
// If the blacklist does not exist, initialize it
const blacklistedTokenIds: Collection = db.collection(
blacklistCollectionName,
);
// Index by tokenId which is unique, ensuring we do not enter the same tokenId more than once
// This also improves query times
blacklistedTokenIds.createIndex({ tokenId: 1 }, { unique: true });
// Initialize blacklist
const result = await blacklistedTokenIds.insertMany(blacklist);
console.log(
`${result.insertedCount} tokens inserted into ${blacklistCollectionName}`,
);
} else {
// If the blacklist exists, log how many entries we have
const blacklistedTokenCount = await db
.collection(blacklistCollectionName)
.countDocuments();
console.log(
`Collection "${blacklistCollectionName}" exists and includes ${blacklistedTokenCount} tokens. Continuing token-server startup...`,
);
}
return db;
};
export const getBlacklistedTokenIds = async (db: Db): Promise<string[]> => {
const collection = db.collection(config.db.collections.blacklist.name);
// Query only for tokenId fields
const projection = { _id: 0, tokenId: 1 };
const tokenIds = await collection
.find({}, { projection })
.map(doc => doc.tokenId)
.toArray();
return tokenIds;
};
export const getOneBlacklistEntry = async (
db: Db,
tokenId: string,
): Promise<BlacklistEntry | null> => {
const collection = db.collection(config.db.collections.blacklist.name);
// Don't return _id
const projection = { _id: 0 };
// Query for a single document where tokenId matches
const result = await collection.findOne({ tokenId }, { projection });
return result as BlacklistEntry | null;
};
+
+export const insertBlacklistEntry = async (
+ db: Db,
+ tokenId: string,
+ entryData: Omit<BlacklistEntry, 'tokenId'>,
+): Promise<InsertOneResult<Document>> => {
+ const collection = db.collection(config.db.collections.blacklist.name);
+
+ // Prepare the document to insert
+ const documentToInsert: BlacklistEntry = {
+ tokenId,
+ ...entryData,
+ };
+
+ // Insert the document into the collection
+ const result = await collection.insertOne(documentToInsert);
+
+ return result;
+};
+
+export const removeBlacklistEntry = async (
+ db: Db,
+ tokenId: string,
+): Promise<DeleteResult> => {
+ const collection = db.collection(config.db.collections.blacklist.name);
+
+ // Define the criteria to match the document for deletion
+ const filter = { tokenId };
+
+ // Delete the document that matches the filter
+ const result = await collection.deleteOne(filter);
+
+ return result;
+};
diff --git a/apps/token-server/src/telegram.ts b/apps/token-server/src/telegram.ts
index a14e9f375..ea527b190 100644
--- a/apps/token-server/src/telegram.ts
+++ b/apps/token-server/src/telegram.ts
@@ -1,184 +1,235 @@
// 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.
import TelegramBot, { ParseMode } from 'node-telegram-bot-api';
import config from '../config';
+import { Db } from 'mongodb';
+import { insertBlacklistEntry, removeBlacklistEntry } from './db';
/**
* telegram.ts
* Methods for working with a Telegram bot
* In token-server, the Telegram bot is used to manage token icon rejections and other admin actions
*/
/**
* Initialize telegram bot for token-server
* The telegram bot is used to notify an admin when a new token icon has been uploaded
* The admin may reject the icon, which will remove the image files from the served directory
* The admin may then un-reject the icon, which will restore the image files to the served directory
* Token icons are rejected for impersonating another asset or being a scam
* @param botId bot id of your telegram bot for token-server
* @param approvedMods An array of numbers of ids of approved mods
* @param fs file system (node fs in prod, memfs in testing)
* @returns
*/
export const initializeTelegramBot = (
botId: string,
approvedMods: number[],
fs: any,
+ db: Db,
): TelegramBot => {
// Initialize telegram bot
const telegramBot = new TelegramBot(botId, {
polling: true,
});
// Add event handler for admin actions
- telegramBot.on('callback_query', function onCallbackQuery(callbackQuery) {
- console.log(JSON.stringify(callbackQuery, null, 2));
- // Get the message ID so the bot can reply to it in the channel
- const msgId = callbackQuery.message?.message_id;
-
- // Get the tokenId
- const tokenId = callbackQuery.data;
-
- // Determine the purpose of this callback
- const isRemovalRequest = fs.existsSync(
- `${config.imageDir}/${
- config.iconSizes[config.iconSizes.length - 1]
- }/${tokenId}.png`,
- );
-
- const approvingUser = callbackQuery.from.id;
- if (!approvedMods.includes(approvingUser)) {
- return console.log(
- `Request to ${
- isRemovalRequest ? `delete` : `restore`
- } tokenIcon for ${tokenId} came from unauthorized telegram user ${approvingUser}, ignoring.`,
+ telegramBot.on(
+ 'callback_query',
+ async function onCallbackQuery(callbackQuery) {
+ console.log(JSON.stringify(callbackQuery, null, 2));
+ // Get the message ID so the bot can reply to it in the channel
+ const msgId = callbackQuery.message?.message_id;
+
+ // Get the tokenId
+ const tokenId = callbackQuery.data;
+
+ // Determine the purpose of this callback
+ const isRemovalRequest = fs.existsSync(
+ `${config.imageDir}/${
+ config.iconSizes[config.iconSizes.length - 1]
+ }/${tokenId}.png`,
);
- }
-
- if (isRemovalRequest) {
- // If this is a removal request, move all sizes to rejected dir
- for (const size of config.iconSizes) {
- fs.renameSync(
- `${config.imageDir}/${size}/${tokenId}.png`,
- `${config.rejectedDir}/${size}/${tokenId}.png`,
- );
- }
- } else {
- // Else we are restoring them
- for (const size of config.iconSizes) {
- fs.renameSync(
- `${config.rejectedDir}/${size}/${tokenId}.png`,
- `${config.imageDir}/${size}/${tokenId}.png`,
+
+ const approvingUser = callbackQuery.from.id;
+ if (!approvedMods.includes(approvingUser)) {
+ return console.log(
+ `Request to ${
+ isRemovalRequest ? `delete` : `restore`
+ } tokenIcon for ${tokenId} came from unauthorized telegram user ${approvingUser}, ignoring.`,
);
}
- }
-
- console.log(
- `Token icon for "${tokenId}" ${
- isRemovalRequest ? `rejected` : `restored`
- } by mod.`,
- );
-
- // Reply to the original tg msg
- // Get msgChannel from a chat
- let msgChannel = callbackQuery.message?.chat?.id;
- // If undefined, get msgChannel from a channel
- if (typeof msgChannel === 'undefined') {
- msgChannel = callbackQuery.message?.sender_chat?.id;
- }
- if (typeof msgId !== 'undefined' && typeof msgChannel !== 'undefined') {
+
+ // Mod taking this action
+ const addedBy =
+ typeof callbackQuery.from.username !== 'undefined'
+ ? callbackQuery.from.username
+ : approvingUser.toString();
+
if (isRemovalRequest) {
- telegramBot.sendMessage(
- msgChannel,
- 'Icon denied and removed from server',
- {
- reply_to_message_id: msgId,
- reply_markup: {
- inline_keyboard: [
- [
- {
- text: 'Changed your mind? Approve it.',
- callback_data: tokenId,
- },
+ // isRemovalRequest
+ // We remove token icons
+ // We add this tokenId to the blacklist
+
+ // Build blacklist metadata
+ const blacklistMetadata = {
+ reason: 'report from icon archon',
+ timestamp: Math.round(new Date().getTime() / 1000),
+ addedBy,
+ };
+
+ // Add this tokenId to blacklist
+ try {
+ await insertBlacklistEntry(db, tokenId!, blacklistMetadata);
+ console.log(`${tokenId} added to blacklist by ${addedBy}`);
+ } catch (err) {
+ console.error(`Error adding ${tokenId} to blacklist`, err);
+ }
+
+ // move token icons of all sizes to rejected dir
+ for (const size of config.iconSizes) {
+ fs.renameSync(
+ `${config.imageDir}/${size}/${tokenId}.png`,
+ `${config.rejectedDir}/${size}/${tokenId}.png`,
+ );
+ }
+ } else {
+ // !isRemovalRequest
+ // We restore token icons
+ // We remove this tokenId from the blacklist
+
+ // Remove this tokenId from blacklist
+ try {
+ await removeBlacklistEntry(db, tokenId!);
+ console.log(
+ `${tokenId} removed from blacklist by ${addedBy}`,
+ );
+ } catch (err) {
+ console.error(
+ `Error removing ${tokenId} from blacklist`,
+ err,
+ );
+ }
+
+ for (const size of config.iconSizes) {
+ fs.renameSync(
+ `${config.rejectedDir}/${size}/${tokenId}.png`,
+ `${config.imageDir}/${size}/${tokenId}.png`,
+ );
+ }
+ }
+
+ console.log(
+ `Token icon for "${tokenId}" ${
+ isRemovalRequest ? `rejected` : `restored`
+ } by mod.`,
+ );
+
+ // Reply to the original tg msg
+ // Get msgChannel from a chat
+ let msgChannel = callbackQuery.message?.chat?.id;
+ // If undefined, get msgChannel from a channel
+ if (typeof msgChannel === 'undefined') {
+ msgChannel = callbackQuery.message?.sender_chat?.id;
+ }
+ if (
+ typeof msgId !== 'undefined' &&
+ typeof msgChannel !== 'undefined'
+ ) {
+ if (isRemovalRequest) {
+ telegramBot.sendMessage(
+ msgChannel,
+ 'Icon denied and removed from server',
+ {
+ reply_to_message_id: msgId,
+ reply_markup: {
+ inline_keyboard: [
+ [
+ {
+ text: 'Changed your mind? Approve it.',
+ callback_data: tokenId,
+ },
+ ],
],
- ],
+ },
},
- },
- );
- } else {
- telegramBot.sendMessage(
- msgChannel,
- 'Icon un-denied and restored to served endpoint',
- {
- reply_to_message_id: msgId,
- reply_markup: {
- inline_keyboard: [
- [
- {
- text: 'Changed your mind? Reject it again.',
- callback_data: tokenId,
- },
+ );
+ } else {
+ telegramBot.sendMessage(
+ msgChannel,
+ 'Icon un-denied and restored to served endpoint',
+ {
+ reply_to_message_id: msgId,
+ reply_markup: {
+ inline_keyboard: [
+ [
+ {
+ text: 'Changed your mind? Reject it again.',
+ callback_data: tokenId,
+ },
+ ],
],
- ],
+ },
},
- },
- );
+ );
+ }
}
- }
- return telegramBot.answerCallbackQuery(callbackQuery.id, {
- text: `Token icons for ${tokenId} ${
- isRemovalRequest ? `removed from ` : `restored to `
- } server`,
- });
- });
+ return telegramBot.answerCallbackQuery(callbackQuery.id, {
+ text: `Token icons for ${tokenId} ${
+ isRemovalRequest ? `removed from ` : `restored to `
+ } server`,
+ });
+ },
+ );
// Return this bot with event handler
return telegramBot;
};
interface TokenInfo {
name: string;
ticker: string;
decimals: number;
url: string;
genesisQty: string;
tokenId: string;
}
/**
* Send a msg to the admin when a new token icon is uploaded
* token icons are auto-approved but may be rejected by a moderator
* @param bot listening telegram bot
* @param channel destination channelID for msg
* @param tokenInfo
*/
export const alertNewTokenIcon = async (
bot: TelegramBot,
channel: string,
tokenInfo: TokenInfo,
) => {
const { tokenId, name, ticker } = tokenInfo;
// Tg msg markdown
- const msg =
- `${name} (${ticker})\n\n` +
- `[Explorer](https://explorer.e.cash/tx/${tokenId})`;
+ // TODO add token type (may need to pass more info from Cashtab)
+ const msg = `[${name}](https://explorer.e.cash/tx/${tokenId})${
+ ticker !== '' ? ` (${ticker})` : ''
+ }`;
let options = {
caption: msg,
parse_mode: 'Markdown' as ParseMode,
reply_markup: {
inline_keyboard: [[{ text: 'Deny', callback_data: tokenId }]],
},
};
return bot.sendPhoto(
channel,
`${config.imageDir}/${
config.iconSizes[config.iconSizes.length - 1]
}/${tokenId}.png`,
options,
);
};
diff --git a/apps/token-server/test/db.test.ts b/apps/token-server/test/db.test.ts
index a8915f9c3..93f07aff3 100644
--- a/apps/token-server/test/db.test.ts
+++ b/apps/token-server/test/db.test.ts
@@ -1,68 +1,126 @@
// 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.
// Mock mongodb
import { MongoClient, Db } from 'mongodb';
import { MongoMemoryServer } from 'mongodb-memory-server';
import {
initializeDb,
getBlacklistedTokenIds,
initialBlacklist,
getOneBlacklistEntry,
+ removeBlacklistEntry,
+ insertBlacklistEntry,
} from '../src/db';
import * as assert from 'assert';
import config from '../config';
// Clone initialBlacklist before initializing the database
// initializeDb(initialBlacklist) will modify the entries by adding an "_id" key
const mockBlacklist = initialBlacklist.map(entry => ({ ...entry }));
describe('db.ts, token-server database unit tests', async function () {
let mongoServer: MongoMemoryServer, testMongoClient: MongoClient;
before(async () => {
// Start mongo memory server before running this suite of unit tests
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
testMongoClient = new MongoClient(mongoUri);
});
after(async () => {
// Shut down mongo memory server after running this suite of unit tests
await testMongoClient.close();
await mongoServer.stop();
});
let testDb: Db;
beforeEach(async () => {
testDb = await initializeDb(testMongoClient, initialBlacklist);
});
afterEach(async () => {
// Wipe the database after each unit test
await testDb.dropDatabase();
});
it('initializeDb returns a mongo db instance of the expected schema', async function () {
const { namespace } = testDb;
assert.strictEqual(namespace, config.db.name);
});
it('getBlacklistedTokenIds can fetch an array of all blacklisted token ids', async function () {
const tokenIds = await getBlacklistedTokenIds(testDb);
assert.deepEqual(
tokenIds,
mockBlacklist.map(entry => entry.tokenId),
);
});
it('getOneBlacklistEntry returns expected information for a blacklisted tokenId', async function () {
const blacklistedTokenId = mockBlacklist[0].tokenId;
const entry = await getOneBlacklistEntry(testDb, blacklistedTokenId);
assert.deepEqual(entry, mockBlacklist[0]);
});
it('getOneBlacklistEntry returns null if tokenId cannot be found on the blacklist', async function () {
const blacklistedTokenId =
'0000000000000000000000000000000000000000000000000000000000000000';
const entry = await getOneBlacklistEntry(testDb, blacklistedTokenId);
assert.equal(entry, null);
});
+ it('removeBlacklistEntry successfully removes an entry from the database', async function () {
+ const tokenIdToBeUnblacklisted = mockBlacklist[0].tokenId;
+ // The entry is in the collection
+ assert.deepEqual(
+ await getOneBlacklistEntry(testDb, tokenIdToBeUnblacklisted),
+ mockBlacklist[0],
+ );
+
+ // Remove entry and get expected status
+ assert.deepEqual(
+ await removeBlacklistEntry(testDb, tokenIdToBeUnblacklisted),
+ {
+ acknowledged: true,
+ deletedCount: 1,
+ },
+ );
+
+ // If we try to find the entry now, we get null
+ assert.equal(
+ await getOneBlacklistEntry(testDb, tokenIdToBeUnblacklisted),
+ null,
+ );
+ });
+ it('insertBlacklistEntry successfully inserts an entry onto the blacklist', async function () {
+ const tokenIdToBeBlacklisted =
+ 'b5fd908eb70768d7b780ebacfdcc5af64aa5bc7a53274fb3e6010baa71840b83';
+ // The entry is not in the collection
+ assert.deepEqual(
+ await getOneBlacklistEntry(testDb, tokenIdToBeBlacklisted),
+ null,
+ );
+ const blacklistMetadata = {
+ reason: 'Impersonates Metamask',
+ timestamp: Math.round(new Date(1730090292122).getTime() / 1000),
+ addedBy: 'Integration test',
+ };
+
+ // Insert the entry and get expected status
+ const insertResult = await insertBlacklistEntry(
+ testDb,
+ tokenIdToBeBlacklisted,
+ blacklistMetadata,
+ );
+ // We get a result like {acknoledged: true, insertedId: new ObjectId(<string>)}
+ assert.equal(insertResult.acknowledged, true);
+ assert.equal('insertedId' in insertResult, true);
+
+ // Now we can find the entry in the collection
+ assert.deepEqual(
+ await getOneBlacklistEntry(testDb, tokenIdToBeBlacklisted),
+ {
+ tokenId: tokenIdToBeBlacklisted,
+ ...blacklistMetadata,
+ },
+ );
+ });
});

File Metadata

Mime Type
text/x-diff
Expires
Wed, May 21, 22:48 (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5866088
Default Alt Text
(28 KB)

Event Timeline