Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F14864841
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
28 KB
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Wed, May 21, 22:48 (23 h, 26 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5866088
Default Alt Text
(28 KB)
Attached To
rABC Bitcoin ABC
Event Timeline
Log In to Comment