diff --git a/apps/alias-server/src/alias.js b/apps/alias-server/src/alias.js index 0c3910e08..f831fd8e9 100644 --- a/apps/alias-server/src/alias.js +++ b/apps/alias-server/src/alias.js @@ -1,204 +1,198 @@ // 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 cashaddr = require('ecashaddrjs'); const config = require('../config'); const log = require('./log'); const { getAliasFromHex, isValidAliasString, getOutputScriptFromAddress, } = require('./utils'); +const { addOneAliasToDb } = require('./db'); module.exports = { getAliasTxs: function (aliasTxHistory, aliasConstants) { const aliasTxCount = aliasTxHistory.length; // Get expected output script match for parseAliasTx // Do it here and not in parseAliasTx so that you don't do it for every single tx // Will all be the same for a given set of tx history const registrationOutputScript = getOutputScriptFromAddress( aliasConstants.registrationAddress, ); // initialize array for all valid aliases const aliasTxs = []; // iterate over history to get all alias:address pairs for (let i = 0; i < aliasTxCount; i += 1) { const thisAliasTx = aliasTxHistory[i]; const parsedAliasTx = module.exports.parseAliasTx( thisAliasTx, aliasConstants, registrationOutputScript, ); if (parsedAliasTx) { aliasTxs.push(parsedAliasTx); } } return aliasTxs; }, parseAliasTx: function (aliasTx, aliasConstants, registrationOutputScript) { // Input: a single tx from chronik tx history // output: false if invalid tx // output: {address: 'address', alias: 'alias', txid} if valid // validate for alias tx const inputZeroOutputScript = aliasTx.inputs[0].outputScript; const registeringAddress = cashaddr.encodeOutputScript( inputZeroOutputScript, ); // Initialize vars used later for validation let aliasFeePaidSats = BigInt(0); let alias; let aliasLength; // Iterate over outputs const outputs = aliasTx.outputs; for (let i = 0; i < outputs.length; i += 1) { const { value, outputScript } = outputs[i]; // If value is 0, parse for OP_RETURN if (value === '0') { // Check for valid alias prefix const validAliasPrefix = outputScript.slice(0, 12) === `6a04${aliasConstants.opCodePrefix}`; if (!validAliasPrefix) { return false; } // Check for valid alias length const aliasLengthHex = outputScript.slice(12, 14); aliasLength = parseInt(aliasLengthHex, 16); // Parse for the alias const aliasHex = outputScript.slice(14, outputScript.length); alias = getAliasFromHex(aliasHex); // Check for valid character set // only lower case roman alphabet a-z // numbers 0 through 9 if (!isValidAliasString(alias)) { return false; } const validAliasLength = aliasLength <= aliasConstants.maxLength && aliasHex.length === 2 * aliasLength; if (!validAliasLength) { return false; } } else { // Check if outputScript matches alias registration address if (outputScript === registrationOutputScript) // If so, then the value here is part of the alias registration fee, aliasFeePaidSats aliasFeePaidSats += BigInt(value); } } // If `alias` is undefined after the above loop, then this is not a valid alias registration tx if (typeof alias === 'undefined') { return false; } // Confirm that the correct fee is paid to the correct address if ( parseInt(aliasFeePaidSats) < aliasConstants.registrationFeesSats[aliasLength] ) { log( `Invalid fee. This transaction paid ${aliasFeePaidSats} sats to register ${alias}. The correct fee for an alias of ${aliasLength} characters is ${aliasConstants.registrationFeesSats[aliasLength]}`, ); return false; } return { address: registeringAddress, alias, txid: aliasTx.txid, // arbitrary to set unconfirmed txs at blockheight of 100,000,000 // note that this constant must be adjusted in the fall of 3910 A.D., assuming 10 min blocks // setting it high instead of zero because it's important we sort aliases by blockheight // for sortAliasTxsByTxidAndBlockheight function blockheight: aliasTx && aliasTx.block ? aliasTx.block.height : config.unconfirmedBlockheight, }; }, sortAliasTxsByTxidAndBlockheight: function (unsortedAliasTxs) { // First, sort the aliases array by alphabetical txid // (alphabetical first to last, 0 comes before a comes before b comes before c, etc) const aliasesTxsSortedByTxid = unsortedAliasTxs.sort((a, b) => { return a.txid.localeCompare(b.txid); }); // Next, sort the aliases array by blockheight. This will preserve the alphabetical txid sort // 735,625 comes before 735,626 comes before 100,000,000 etc const aliasTxsSortedByTxidAndBlockheight = aliasesTxsSortedByTxid.sort( (a, b) => { return a.blockheight - b.blockheight; }, ); return aliasTxsSortedByTxidAndBlockheight; }, - getValidAliasRegistrations: function (registeredAliases, unsortedAliasTxs) { - /* Function that takes an array of already-registered aliases (strings) - * and an arbitrary collection of alias-prefixed txs from the alias registration - * address. + registerAliases: async function (db, unsortedConfirmedAliasTxs) { + /* Add new valid aliases registration txs to the database. Return an array of what was added. * - * Outputs new valid alias registrations by discarding any repeated registrations - * as invalid. + * Input parameters + * db - the app database + * unsortedConfirmedAliasTxs - array, arbitrary collection of confirmed alias-prefixed txs + * at the alias registration address * - * Will get all valid alias registrations if given the full tx history and an empty array - * for registeredAliases + * Outputs + * - The function adds new valid alias txs to the database + * - An array of objects, each one a new valid alias registration tx that was added to the database + * + * Will get all valid alias registrations if given the full tx history and + * the database is empty */ // Sort aliases such that the earliest aliases are the valid ones const aliasesSortedByTxidAndBlockheight = - module.exports.sortAliasTxsByTxidAndBlockheight(unsortedAliasTxs); + module.exports.sortAliasTxsByTxidAndBlockheight( + unsortedConfirmedAliasTxs, + ); // Initialize arrays to store alias registration info let validAliasRegistrations = []; // Iterate over sorted aliases starting from oldest registrations to newest // (and alphabetically first txids to last) for (let i = 0; i < aliasesSortedByTxidAndBlockheight.length; i += 1) { const thisAliasTx = aliasesSortedByTxidAndBlockheight[i]; - const { alias, blockheight } = thisAliasTx; - - // If you haven't seen this alias yet, it's a valid registered alias - if (!registeredAliases.includes(alias)) { - // If the tx is confirmed, add this alias to the registeredAlias array - registeredAliases.push(alias); - // If the tx is confirmed, - if (blockheight < 100000000) { - // Add thisAliasObject to the validAliasObjects array - validAliasRegistrations.push(thisAliasTx); - } - } else { - // If you've already seen it at an earlier blockheight or earlier alphabetical txid, - // then this is not a valid registration. - // Do not include it in valid registrations - // Note, we could just remove this else block. But it's useful for code readability. - continue; + /* If the alias isn't in the database, it's valid and should be added + */ + + // If database add is successful, + // add thisAliasObject to the validAliasObjects array + if (await addOneAliasToDb(db, thisAliasTx)) { + // Because thisAliasTx receives an "_id" key on being added to the db, + // clone it without this field to return + const { address, alias, blockheight, txid } = thisAliasTx; + validAliasRegistrations.push({ + address, + alias, + blockheight, + txid, + }); } } return validAliasRegistrations; }, - getAliasStringsFromValidAliasTxs: function (validAliases) { - /* Input - * validAliases, an array of alias registrations objects like those - * stored in the validAliases collection of the database - * Output - * an array of strings of all alias registrations - */ - return validAliases.map(aliasObj => { - return aliasObj.alias; - }); - }, }; diff --git a/apps/alias-server/src/db.js b/apps/alias-server/src/db.js index 6c50e986d..52bc12f42 100644 --- a/apps/alias-server/src/db.js +++ b/apps/alias-server/src/db.js @@ -1,141 +1,160 @@ // 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 log = require('./log'); const config = require('../config'); +const MONGO_DB_ERRORCODES = { + duplicateKey: 11000, +}; + module.exports = { initializeDb: async function (mongoClient) { // Use connect method to connect to the server await mongoClient.connect(); log('Connected successfully to MongoDB server'); const db = mongoClient.db(config.database.name); // Enforce unique aliases db.collection(config.database.collections.validAliases).createIndex( { alias: 1, }, { unique: true, }, ); // Check if serverState collection exists const serverStateExists = (await db .collection(config.database.collections.serverState) .countDocuments()) > 0; // If serverState collection does not exist if (!serverStateExists) { // Create it await db.createCollection( config.database.collections.serverState, // serverState may only have one document // 4096 is max size in bytes, required by mongo // 4096 is smallest max size allowed { capped: true, size: 4096, max: 1 }, ); // Initialize server with zero alias txs processed await module.exports.updateServerState(db, { processedConfirmedTxs: 0, processedBlockheight: 0, }); log(`Initialized serverState on app startup`); } log(`Configured connection to database ${config.database.name}`); return db; }, getServerState: async function (db) { let serverStateArray; try { serverStateArray = await db .collection(config.database.collections.serverState) .find() // We don't need the _id field .project({ _id: 0 }) .next(); // Only 1 document in collection return serverStateArray; } catch (err) { log(`Error in determining serverState.`, err); return false; } }, updateServerState: async function (db, newServerState) { try { const { processedConfirmedTxs, processedBlockheight } = newServerState; if ( typeof processedConfirmedTxs !== 'number' || typeof processedBlockheight !== 'number' ) { return false; } // An empty document as a query i.e. {} will update the first // document returned in the collection // serverState only has one document const serverStateQuery = {}; const serverStateUpdate = { $set: { processedConfirmedTxs, processedBlockheight, }, }; // If you are running the server for the first time and there is no // serverState in the db, create it const serverStateOptions = { upsert: true }; await db .collection(config.database.collections.serverState) .updateOne( serverStateQuery, serverStateUpdate, serverStateOptions, ); return true; } catch (err) { // If this isn't updated, the server will process too many txs next time // TODO Let the admin know. This won't impact parsing but will cause processing too many txs log(`Error in function updateServerState.`, err); return false; } }, + addOneAliasToDb: async function (db, newAliasTx) { + try { + await db + .collection(config.database.collections.validAliases) + .insertOne(newAliasTx); + return true; + } catch (err) { + // Only log some error other than duplicate key error + if (err && err.code !== MONGO_DB_ERRORCODES.duplicateKey) { + log(`Error in function addOneAliasToDb:`); + log(err); + } + return false; + } + }, addAliasesToDb: async function (db, newValidAliases) { let validAliasesAddedToDbSuccess; try { validAliasesAddedToDbSuccess = await db .collection(config.database.collections.validAliases) .insertMany(newValidAliases); log( `Inserted ${validAliasesAddedToDbSuccess.insertedCount} reserved aliases into ${config.database.collections.validAliases}`, ); return true; } catch (err) { log(`Error in function addAliasesToDb.`, err); return false; } }, getAliasesFromDb: async function (db) { let validAliasesInDb; try { validAliasesInDb = await db .collection(config.database.collections.validAliases) .find() .sort({ blockheight: 1 }) .project({ _id: 0 }) .toArray(); return validAliasesInDb; } catch (err) { log( `Error in determining validAliasesInDb in function getValidAliasesFromDb.`, err, ); return false; } }, }; diff --git a/apps/alias-server/src/events.js b/apps/alias-server/src/events.js index def94cc00..c8b254408 100644 --- a/apps/alias-server/src/events.js +++ b/apps/alias-server/src/events.js @@ -1,148 +1,194 @@ // 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 log = require('./log'); -const { wait } = require('./utils'); +const { wait, removeUnconfirmedTxsFromTxHistory } = require('./utils'); const { isFinalBlock } = require('./rpc'); +const { getServerState, updateServerState } = require('./db'); +const { getUnprocessedTxHistory } = require('./chronik'); +const { getAliasTxs, registerAliases } = require('./alias'); module.exports = { handleAppStartup: async function ( chronik, db, telegramBot, channelId, avalancheRpc, ) { log(`Checking for new aliases on startup`); // If this is app startup, get the latest tipHash and tipHeight by querying the blockchain // Get chain tip let chaintipInfo; try { chaintipInfo = await chronik.blockchainInfo(); } catch (err) { log(`Error in chronik.blockchainInfo() in handleAppStartup()`, err); // Server will wait until receiving ws msg to handleBlockConnected() return false; } const { tipHash, tipHeight } = chaintipInfo; // Validate for good chronik response if (typeof tipHash === 'string' && typeof tipHeight === 'number') { return module.exports.handleBlockConnected( chronik, db, telegramBot, channelId, avalancheRpc, tipHash, tipHeight, ); } return false; }, handleBlockConnected: async function ( chronik, db, telegramBot, channelId, avalancheRpc, tipHash, tipHeight, ) { /* * BlockConnected callback * * This is where alias-server queries the blockchain for new transactions and * parses those transactions to determine if any are valid alias registrations * * The database may only be updated if we have a known blockhash and height with * isFinalBlock = true confirmed by avalanche * * A number of error conditions may cause parseWebsocketMessage to exit before any update to * the database occurs. * * If alias-server determines a blockhash and height with isFinalBlock === true, * valid alias registrations will be processed up to and including that blockheight * * Otherwise parseWebsocketMessage will exit before any updates are made to the database * * Note: websockets disconnect and reconnect frequently. It cannot be assumed that * every found block will triggger parseWebsocketMessage. So, parseWebsocketMessage must be designed such that * it will always update for all unseen valid alias registrations. * */ if (typeof tipHeight === 'undefined') { let blockResult; try { blockResult = await chronik.block(tipHash); // chronik blockdetails returns the block height at the 'blockInfo.height' key tipHeight = blockResult.blockInfo.height; } catch (err) { log( `Error in chronik.block(${tipHash} in handleBlockConnected(). Exiting function.`, err, ); // Exit handleBlockConnected on chronik error return false; } } // Initialize isAvalancheFinalized as false. Only set to true if you // prove it so with a node rpc call let isAvalancheFinalized = false; for (let i = 0; i < config.avalancheCheckCount; i += 1) { // Check to see if block tipHash has been finalized by avalanche try { isAvalancheFinalized = await isFinalBlock( avalancheRpc, tipHash, ); } catch (err) { log(`Error in isFinalBlock for ${tipHash}`, err); } if (isAvalancheFinalized) { // If isAvalancheFinalized, stop checking break; } wait(config.avalancheCheckWaitInterval); } if (!isAvalancheFinalized) { log( `Block ${tipHash} is not avalanche finalized after ${ config.avalancheCheckWaitInterval * config.avalancheCheckCount } ms. Exiting handleBlockConnected().`, ); return false; } - // TODO Get the valid aliases already in the db + const serverState = await getServerState(db); + if (!serverState) { + // TODO notify admin + return false; + } + + const { processedBlockheight, processedConfirmedTxs } = serverState; + + // If serverState is, somehow, ahead of the calling block, return false + if (processedBlockheight >= tipHeight) { + // TODO notify admin + return false; + } - // TODO get server state - // processedConfirmedTxs - count of processed confirmed txs - // processedBlockheight - highest blockheight seen by the server + const allUnprocessedTxs = await getUnprocessedTxHistory( + chronik, + config.aliasConstants.registrationAddress, + processedBlockheight, + processedConfirmedTxs, + ); + + // Remove unconfirmed txs as these are not eligible for valid alias registrations + const confirmedUnprocessedTxs = + removeUnconfirmedTxsFromTxHistory(allUnprocessedTxs); - // TODO get set of transactions not yet processed by the server - // If app startup, this is full tx history of alias registration address + // Get all potentially valid alias registrations + // i.e. correct fee is paid, prefix is good, everything good but not yet checked against + // conflicting aliases that registered earlier or have alphabetically earlier txid in + // same block + const unprocessedAliasTxs = getAliasTxs( + confirmedUnprocessedTxs, + config.aliasConstants, + ); - // TODO parse tx history for latest valid alias registrations - // with valid format and fee + // Add new valid alias txs to the database and get a list of what was added + await registerAliases(db, unprocessedAliasTxs); - // TODO update database with latest valid alias information + // New processedBlockheight is the highest one seen, or the + // height of the first entry of the confirmedUnprocessedTxs array + // New processedConfirmedTxs is determined by adding the count of now-processed txs + const newServerState = { + processedBlockheight: tipHeight, + processedConfirmedTxs: + processedConfirmedTxs + confirmedUnprocessedTxs.length, + }; + // Update serverState + const serverStateUpdated = await updateServerState(db, newServerState); + if (!serverStateUpdated) { + // Don't exit, since you've already added aliases to the db here + // App will run next on the old server state, so will re-process txs + // These can't be added to the db, so you will get errors + // If you get here, there is something wrong with the server that needs to be checked out + // TODO notify admin + log( + `serverState failed to update to new serverState`, + newServerState, + ); + } - // TODO update server state - // TODO If you have new aliases to add to the db, add them + send a tg msg - // TODO If not, exit loop + // TODO telegram notifications for new alias registrations log( `Alias registrations updated to block ${tipHash} at height ${tipHeight}`, ); return `Alias registrations updated to block ${tipHash} at height ${tipHeight}`; }, }; diff --git a/apps/alias-server/test/aliasTests.js b/apps/alias-server/test/aliasTests.js index bff63ab90..3c1db2155 100644 --- a/apps/alias-server/test/aliasTests.js +++ b/apps/alias-server/test/aliasTests.js @@ -1,199 +1,256 @@ // 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 assert = require('assert'); const config = require('../config'); const { parseAliasTx, getAliasTxs, sortAliasTxsByTxidAndBlockheight, - getValidAliasRegistrations, - getAliasStringsFromValidAliasTxs, + registerAliases, } = require('../src/alias'); -const { getOutputScriptFromAddress } = require('../src/utils'); +const { + getOutputScriptFromAddress, + removeUnconfirmedTxsFromTxHistory, +} = require('../src/utils'); const { testAddressAliases, testAddressAliasesWithUnconfirmedTxs, aliases_fake_data, } = require('./mocks/aliasMocks'); +// Mock mongodb +const { MongoClient } = require('mongodb'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { initializeDb, addAliasesToDb } = require('../src/db'); + +describe('alias-server alias.js', async function () { + let mongoServer, testMongoClient; + 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); + }); -describe('alias-server alias.js', function () { + after(async () => { + // Shut down mongo memory server after running this suite of unit tests + await testMongoClient.close(); + await mongoServer.stop(); + }); it('Correctly parses a 5-character alias transaction', function () { const registrationOutputScript = getOutputScriptFromAddress( config.aliasConstants.registrationAddress, ); assert.deepEqual( parseAliasTx( testAddressAliases.txHistory[ testAddressAliases.txHistory.findIndex( i => i.txid === '9d9fd465f56a7946c48b2e214386b51d7968a3a40d46cc697036e4fc1cc644df', ) ], config.aliasConstants, registrationOutputScript, ), testAddressAliases.allAliasTxs[ testAddressAliases.allAliasTxs.findIndex( i => i.txid === '9d9fd465f56a7946c48b2e214386b51d7968a3a40d46cc697036e4fc1cc644df', ) ], ); }); it('Correctly parses a 6-character alias transaction', function () { const registrationOutputScript = getOutputScriptFromAddress( config.aliasConstants.registrationAddress, ); assert.deepEqual( parseAliasTx( testAddressAliases.txHistory[ testAddressAliases.txHistory.findIndex( i => i.txid === '36fdab59d25625b6ff3661aa5ab22a4893698fa5618e5e958e1d75bf921e6107', ) ], config.aliasConstants, registrationOutputScript, ), testAddressAliases.allAliasTxs[ testAddressAliases.allAliasTxs.findIndex( i => i.txid === '36fdab59d25625b6ff3661aa5ab22a4893698fa5618e5e958e1d75bf921e6107', ) ], ); }); it('Returns false for an eToken transaction', function () { const registrationOutputScript = getOutputScriptFromAddress( config.aliasConstants.registrationAddress, ); assert.deepEqual( parseAliasTx( testAddressAliases.txHistory[ testAddressAliases.txHistory.findIndex( i => i.txid === 'feafd053d4166601d42949a768b9c3e8ee1f27912fc84b6190aeb022fba7fa39', ) ], config.aliasConstants, registrationOutputScript, ), false, ); }); it('Returns false for a standard tx without an OP_RETURN', function () { const registrationOutputScript = getOutputScriptFromAddress( config.aliasConstants.registrationAddress, ); assert.deepEqual( parseAliasTx( testAddressAliases.txHistory[ testAddressAliases.txHistory.findIndex( i => i.txid === '7440fb8810610f29197701c53f4a29479a9aede8c66feabb44b049232f990791', ) ], config.aliasConstants, registrationOutputScript, ), false, ); }); it('Correctly parses all aliases through transactions at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8', function () { assert.deepEqual( getAliasTxs(testAddressAliases.txHistory, config.aliasConstants), testAddressAliases.allAliasTxs, ); }); it('Correctly parses all aliases through transactions at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8 including unconfirmed txs', function () { assert.deepEqual( getAliasTxs( testAddressAliasesWithUnconfirmedTxs.txHistory, config.aliasConstants, ), testAddressAliasesWithUnconfirmedTxs.allAliasTxs, ); }); it('Correctly sorts simple template alias txs including unconfirmed alias txs by blockheight and txid', function () { assert.deepEqual( sortAliasTxsByTxidAndBlockheight(aliases_fake_data.unsortedSimple), aliases_fake_data.sortedSimple, ); }); it('Correctly sorts template alias txs including unconfirmed alias txs by blockheight and txid', function () { assert.deepEqual( sortAliasTxsByTxidAndBlockheight(aliases_fake_data.allAliasTxs), aliases_fake_data.allAliasTxsSortedByTxidAndBlockheight, ); }); it('Correctly sorts alias txs including unconfirmed alias txs by blockheight and txid', function () { assert.deepEqual( sortAliasTxsByTxidAndBlockheight( testAddressAliasesWithUnconfirmedTxs.allAliasTxs, ), testAddressAliasesWithUnconfirmedTxs.allAliasTxsSortedByTxidAndBlockheight, ); }); - it('Correctly returns only valid alias registrations at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8', function () { + it('Correctly returns only valid alias registrations at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8 starting with an empty database', async function () { + // Initialize db before each unit test + let testDb = await initializeDb(testMongoClient); + + // Clone unprocessedAliasTxs since the act of adding to db gives it an _id field + const mockAllAliasTxs = JSON.parse( + JSON.stringify(testAddressAliases.allAliasTxs), + ); + assert.deepEqual( - getValidAliasRegistrations([], testAddressAliases.allAliasTxs), + await registerAliases(testDb, mockAllAliasTxs), testAddressAliases.validAliasTxs, ); + + // Wipe the database after this unit test + await testDb.dropDatabase(); }); - it('Correctly returns only new valid alias registrations at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8 given partial txHistory and list of registered aliases', function () { + it('Correctly returns only new valid alias registrations at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8 given partial txHistory and list of registered aliases', async function () { // Take only txs after registration of alias 'bytesofman' + // Note: allAliasTxs are sorted with most recent txs first const unprocessedAliasTxs = testAddressAliases.allAliasTxs.slice( + 0, testAddressAliases.allAliasTxs.findIndex( i => i.alias === 'bytesofman', ), ); + + // Clone unprocessedAliasTxs since the act of adding to db gives it an _id field + const mockUnprocessedAliasTxs = JSON.parse( + JSON.stringify(unprocessedAliasTxs), + ); + // Get list of all valid alias registrations before 'bytesofman' - const registeredAliases = getAliasStringsFromValidAliasTxs( - testAddressAliases.validAliasTxs.slice( - 0, - testAddressAliases.validAliasTxs.findIndex( - i => i.alias === 'bytesofman', - ), - ), + // Note: validAliasTxs are sorted with most recent txs last + // Note: you want to include bytesofman here + const registeredAliases = testAddressAliases.validAliasTxs.slice( + 0, + testAddressAliases.validAliasTxs.findIndex( + i => i.alias === 'bytesofman', + ) + 1, ); // newlyValidAliases will be all the valid alias txs registered after 'bytesofman' + // Note: you do not want bytesofman in this set const newlyValidAliases = testAddressAliases.validAliasTxs.slice( testAddressAliases.validAliasTxs.findIndex( i => i.alias === 'bytesofman', - ), + ) + 1, + ); + // Initialize db before each unit test + let testDb = await initializeDb(testMongoClient); + + // mockRegisteredAliases needs to be a clone of the mock because + // each object gets an _id field when added to the database + const mockRegisteredAliases = JSON.parse( + JSON.stringify(registeredAliases), ); + // Add expected registered aliases to the db + await addAliasesToDb(testDb, mockRegisteredAliases); assert.deepEqual( - getValidAliasRegistrations(registeredAliases, unprocessedAliasTxs), + await registerAliases(testDb, mockUnprocessedAliasTxs), newlyValidAliases, ); + // Wipe the database after this unit test + await testDb.dropDatabase(); }); - it('Correctly returns valid alias registrations at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8 given some unconfirmed txs in history', function () { - assert.deepEqual( - getValidAliasRegistrations( - [], - testAddressAliasesWithUnconfirmedTxs.allAliasTxs, - ), + it('Correctly returns valid alias registrations at test address ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8 given some unconfirmed txs in history', async function () { + // Initialize db before each unit test + let testDb = await initializeDb(testMongoClient); - testAddressAliasesWithUnconfirmedTxs.validAliasTxs, + // Start with the raw tx history + + // First, remove the unconfirmed txs, following the logic used in events.js + const confirmedUnsortedTxs = removeUnconfirmedTxsFromTxHistory( + testAddressAliasesWithUnconfirmedTxs.txHistory, ); - }); - it('getAliasStringsFromValidAliasTxs returns an array of string of the alias object key from an array of valid alias registrations', function () { + // Get the alias txs + const confirmedUnsortedAliasTxs = getAliasTxs( + confirmedUnsortedTxs, + config.aliasConstants, + ); + + // This tests startup condition, so add no aliases to the database assert.deepEqual( - getAliasStringsFromValidAliasTxs( - testAddressAliasesWithUnconfirmedTxs.validAliasTxs, - ), - testAddressAliasesWithUnconfirmedTxs.validAliasStrings, + await registerAliases(testDb, confirmedUnsortedAliasTxs), + testAddressAliasesWithUnconfirmedTxs.validAliasTxs, ); + // Wipe the database after this unit test + await testDb.dropDatabase(); }); }); diff --git a/apps/alias-server/test/chronikWsHandlerTests.js b/apps/alias-server/test/chronikWsHandlerTests.js index 3c1db990d..283f57de7 100644 --- a/apps/alias-server/test/chronikWsHandlerTests.js +++ b/apps/alias-server/test/chronikWsHandlerTests.js @@ -1,241 +1,299 @@ // 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 assert = require('assert'); +const config = require('../config'); const cashaddr = require('ecashaddrjs'); const { initializeWebsocket, parseWebsocketMessage, } = require('../src/chronikWsHandler'); const { MockChronikClient } = require('./mocks/chronikMock'); const { mockBlock } = require('./mocks/chronikResponses'); const mockSecrets = require('../secrets.sample'); const MockAdapter = require('axios-mock-adapter'); const axios = require('axios'); +// Mock mongodb +const { initializeDb } = require('../src/db'); +const { MongoClient } = require('mongodb'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { testAddressAliases } = require('./mocks/aliasMocks'); describe('alias-server chronikWsHandler.js', async function () { + let mongoServer, testMongoClient; + 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; + beforeEach(async () => { + // Initialize db before each unit test + testDb = await initializeDb(testMongoClient); + }); + afterEach(async () => { + // Wipe the database after each unit test + await testDb.dropDatabase(); + }); it('initializeWebsocket returns expected websocket object for a p2pkh address', async function () { const wsTestAddress = 'ecash:qp3c268rd5946l2f5m5es4x25f7ewu4sjvpy52pqa8'; const { type, hash } = cashaddr.decode(wsTestAddress, true); // Initialize chronik mock const mockedChronik = new MockChronikClient(wsTestAddress, []); const db = null; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const result = await initializeWebsocket( mockedChronik, wsTestAddress, db, telegramBot, channelId, avalancheRpc, ); // Confirm websocket opened assert.strictEqual(mockedChronik.wsWaitForOpenCalled, true); // Confirm subscribe was called assert.deepEqual(mockedChronik.wsSubscribeCalled, true); // Confirm ws is subscribed to expected type and hash assert.deepEqual(result._subs, [ { scriptType: type, scriptPayload: hash }, ]); }); it('initializeWebsocket returns expected websocket object for a p2sh address', async function () { const wsTestAddress = 'ecash:prfhcnyqnl5cgrnmlfmms675w93ld7mvvqd0y8lz07'; const { type, hash } = cashaddr.decode(wsTestAddress, true); // Initialize chronik mock const mockedChronik = new MockChronikClient(); const db = null; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const result = await initializeWebsocket( mockedChronik, wsTestAddress, db, telegramBot, channelId, avalancheRpc, ); // Confirm websocket opened assert.strictEqual(mockedChronik.wsWaitForOpenCalled, true); // Confirm subscribe was called assert.deepEqual(mockedChronik.wsSubscribeCalled, true); // Confirm ws is subscribed to expected type and hash assert.deepEqual(result._subs, [ { scriptType: type, scriptPayload: hash }, ]); }); it('parseWebsocketMessage correctly processes a chronik websocket BlockConnected message if block is avalanche finalized', async function () { // Initialize chronik mock const mockedChronik = new MockChronikClient(); - const db = null; + const db = testDb; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const wsMsg = { type: 'BlockConnected', blockHash: '000000000000000015713b0407590ab1481fd7b8430f87e19cf768bec285ad55', }; const mockBlock = { blockInfo: { height: 786878, }, }; // Tell mockedChronik what response we expect mockedChronik.setMock('block', { input: wsMsg.blockHash, output: mockBlock, }); + // Add tx history to mockedChronik + // Set the script + const { type, hash } = cashaddr.decode( + config.aliasConstants.registrationAddress, + true, + ); + mockedChronik.setScript(type, hash); + // Set the mock tx history + mockedChronik.setTxHistory(testAddressAliases.txHistory); + // Mock avalanche RPC call // onNoMatch: 'throwException' helps to debug if mock is not being used const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); // Mock response for rpc return of true for isfinalblock method mock.onPost().reply(200, { result: true, error: null, id: 'isfinalblock', }); const result = await parseWebsocketMessage( mockedChronik, db, telegramBot, channelId, avalancheRpc, wsMsg, ); assert.strictEqual( result, `Alias registrations updated to block ${wsMsg.blockHash} at height ${mockBlock.blockInfo.height}`, ); }); it('parseWebsocketMessage calls handleBlockConnected, which exits if block is not avalanche finalized', async function () { // Initialize chronik mock const mockedChronik = new MockChronikClient(); const db = null; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const wsMsg = { type: 'BlockConnected', blockHash: '000000000000000015713b0407590ab1481fd7b8430f87e19cf768bec285ad55', }; // Tell mockedChronik what response we expect mockedChronik.setMock('block', { input: wsMsg.blockHash, output: mockBlock, }); // Mock avalanche RPC call // onNoMatch: 'throwException' helps to debug if mock is not being used const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); // Mock response for rpc return of true for isfinalblock method mock.onPost().reply(200, { result: false, error: null, id: 'isfinalblock', }); const result = await parseWebsocketMessage( mockedChronik, db, telegramBot, channelId, avalancheRpc, wsMsg, ); assert.deepEqual(result, false); }); it('If parseWebsocketMessage is called before a previous call to handleBlockConnected has completed, the next call to handleBlockConnected will not enter until the first is completed', async function () { // Initialize mocks for the first call to parseWebsocketMessage const mockedChronik = new MockChronikClient(); - const db = null; + const db = testDb; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const wsMsg = { type: 'BlockConnected', blockHash: '000000000000000015713b0407590ab1481fd7b8430f87e19cf768bec285ad55', }; const mockBlock = { blockInfo: { height: 786878, }, }; // Tell mockedChronik what response we expect mockedChronik.setMock('block', { input: wsMsg.blockHash, output: mockBlock, }); + // Add tx history to mockedChronik + // Set the script + const { type, hash } = cashaddr.decode( + config.aliasConstants.registrationAddress, + true, + ); + mockedChronik.setScript(type, hash); + // Set the mock tx history + mockedChronik.setTxHistory(testAddressAliases.txHistory); + // Mock avalanche RPC call // onNoMatch: 'throwException' helps to debug if mock is not being used const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); // Mock response for rpc return of true for isfinalblock method mock.onPost().reply(200, { result: true, error: null, id: 'isfinalblock', }); // Initialize mocks for second call to parseWebsocketMessage const nextMockedChronik = new MockChronikClient(); const nextWsMsg = { type: 'BlockConnected', blockHash: '000000000000000001db7132241fc59ec9de423db1f5061115928d58b38f0b8f', }; const nextMockBlock = { blockInfo: { height: 786879, }, }; + // Tell mockedChronik what response we expect nextMockedChronik.setMock('block', { input: nextWsMsg.blockHash, output: nextMockBlock, }); + + // Add tx history to nextMockedChronik + // Set the script + nextMockedChronik.setScript(type, hash); + // Set the mock tx history + // For now, assume it's the same as before, i.e. no new txs found + nextMockedChronik.setTxHistory(testAddressAliases.txHistory); + const firstCallPromise = parseWebsocketMessage( mockedChronik, db, telegramBot, channelId, avalancheRpc, wsMsg, ); const secondCallPromise = parseWebsocketMessage( nextMockedChronik, db, telegramBot, channelId, avalancheRpc, nextWsMsg, ); // Call the functions concurrently const results = await Promise.all([ firstCallPromise, secondCallPromise, ]); assert.deepEqual(results, [ `Alias registrations updated to block ${wsMsg.blockHash} at height ${mockBlock.blockInfo.height}`, `Alias registrations updated to block ${nextWsMsg.blockHash} at height ${nextMockBlock.blockInfo.height}`, ]); }); }); diff --git a/apps/alias-server/test/dbTests.js b/apps/alias-server/test/dbTests.js index 540f778e8..4a3e74975 100644 --- a/apps/alias-server/test/dbTests.js +++ b/apps/alias-server/test/dbTests.js @@ -1,137 +1,226 @@ // 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 assert = require('assert'); const { initializeDb, getServerState, updateServerState, + addOneAliasToDb, addAliasesToDb, getAliasesFromDb, } = require('../src/db'); // Mock mongodb const { MongoClient } = require('mongodb'); const { MongoMemoryServer } = require('mongodb-memory-server'); const { testAddressAliases } = require('./mocks/aliasMocks'); describe('alias-server db.js', async function () { let mongoServer, testMongoClient; 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; beforeEach(async () => { // Initialize db before each unit test testDb = await initializeDb(testMongoClient); }); 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, 'ecashAliases'); }); it('getServerState returns expected initial server state on initialized database', async function () { // Check that serverState was initialized properly const initialServerState = await getServerState(testDb); assert.deepEqual(initialServerState, { processedBlockheight: 0, processedConfirmedTxs: 0, }); }); it('updateServerState modifies serverState correctly', async function () { const newServerState = { processedConfirmedTxs: 1, processedBlockheight: 700000, }; // Modify serverState const serverStateModifiedSuccess = await updateServerState( testDb, newServerState, ); // Fetch the now-modified serverState const fetchedServerState = await getServerState(testDb); // Confirm it has been modified assert.deepEqual(fetchedServerState, newServerState); // Confirm updateServerState returned true assert.strictEqual(serverStateModifiedSuccess, true); }); it('updateServerState returns false if provided with improperly formatted new serverState', async function () { const newServerState = { // typo processedConfirmedTx: 1, processedBlockheight: 700000, }; // Modify serverState const serverStateModifiedSuccess = await updateServerState( testDb, newServerState, ); // Confirm updateServerState returned false assert.strictEqual(serverStateModifiedSuccess, false); }); it('If serverState exists on startup, initializeDb does not overwrite it', async function () { // Change serverState const newServerState = { // typo processedConfirmedTxs: 1, processedBlockheight: 700000, }; await updateServerState(testDb, newServerState); // Start up the app again const testDbOnRestart = await initializeDb(testMongoClient); const fetchedServerStateOnRestart = await getServerState( testDbOnRestart, ); // Verify serverState has not reverted to initial value assert.deepEqual(fetchedServerStateOnRestart, newServerState); }); it('addAliasesToDb successfully adds new valid aliases to an empty collection', async function () { // newValidAliases needs to be a clone of the mock because // each object gets an _id field when added to the database const newValidAliases = JSON.parse( JSON.stringify(testAddressAliases.validAliasTxs), ); await addAliasesToDb(testDb, newValidAliases); // Get the newly added valid aliases // Note we return valid aliases without the database _id field const addedValidAliases = await getAliasesFromDb(testDb); // Verify addedValidAliases match the added mock assert.deepEqual(addedValidAliases, testAddressAliases.validAliasTxs); }); + it('addOneAliasToDb successfully adds a new valid alias to an empty collection', async function () { + // newValidAliases needs to be a clone of the mock because + // each object gets an _id field when added to the database + const newValidAliases = JSON.parse( + JSON.stringify(testAddressAliases.validAliasTxs), + ); + const aliasAddedSuccess = await addOneAliasToDb( + testDb, + newValidAliases[0], + ); + // Get the newly added valid aliases + // Note we return valid aliases without the database _id field + const addedValidAliases = await getAliasesFromDb(testDb); + + // Verify the function returns true on alias add success + assert.strictEqual(aliasAddedSuccess, true); + // Verify the database has the expected alias + assert.deepEqual(addedValidAliases, [ + testAddressAliases.validAliasTxs[0], + ]); + }); + it('addOneAliasToDb successfully adds a new valid alias to an existing collection', async function () { + // newValidAliases needs to be a clone of the mock because + // each object gets an _id field when added to the database + const newValidAliases = JSON.parse( + JSON.stringify(testAddressAliases.validAliasTxs), + ); + // Pre-populate the aliases collection + await addAliasesToDb(testDb, newValidAliases); + + const newMockAlias = { + address: 'ecash:qrmz0egsqxj35x5jmzf8szrszdeu72fx0uxgwk3r48', + alias: 'rico', + txid: '3ff9c28fa07cb88c87000ef0f5ee61953d874ffade154cd3f88fd60b88ea2879', + blockheight: 787674, + }; + + // clone to check unit test result as _id will be added to newMockAlias + const newMockAliasClone = JSON.parse(JSON.stringify(newMockAlias)); + + // Add an alias tx that does not exist + const aliasAddedSuccess = await addOneAliasToDb(testDb, newMockAlias); + // Get the newly added valid aliases + // Note we return valid aliases without the database _id field + const addedValidAliases = await getAliasesFromDb(testDb); + + // Verify the function returns true on alias add success + assert.strictEqual(aliasAddedSuccess, true); + // Verify the database has the expected alias + assert.deepEqual( + addedValidAliases, + testAddressAliases.validAliasTxs.concat(newMockAliasClone), + ); + }); + it('addOneAliasToDb returns false and fails to add an alias if it is already in the database', async function () { + // newValidAliases needs to be a clone of the mock because + // each object gets an _id field when added to the database + const newValidAliases = JSON.parse( + JSON.stringify(testAddressAliases.validAliasTxs), + ); + // Pre-populate the aliases collection + await addAliasesToDb(testDb, newValidAliases); + + const newMockAliasTxRegisteringExistingAlias = { + address: 'ecash:qzvydd4n3lm3xv62cx078nu9rg0e3srmqq0knykfed', + alias: 'foo', + txid: 'f41ccfbd88d228bbb695b771dd0c266b0351eda9a35aeb8c5e3cb7670e7e17cc', + blockheight: 776576, + }; + + // Add an alias tx that does not exist + const aliasAddedSuccess = await addOneAliasToDb( + testDb, + newMockAliasTxRegisteringExistingAlias, + ); + // Get the newly added valid aliases + // Note we return valid aliases without the database _id field + const addedValidAliases = await getAliasesFromDb(testDb); + + // Verify the function returns true on alias add success + assert.strictEqual(aliasAddedSuccess, false); + // Verify the database has the expected aliases (without the failed add) + assert.deepEqual(addedValidAliases, testAddressAliases.validAliasTxs); + }); + it('getAliasesFromDb returns an empty array if no aliases have been added to the collection', async function () { + const validAliases = await getAliasesFromDb(testDb); + assert.deepEqual(validAliases, []); + }); it('addAliasesToDb returns false if you attempt to add aliases whose txid already exists in the database', async function () { // Startup the app and initialize serverState const testDb = await initializeDb(testMongoClient); // newValidAliases needs to be a clone of the mock because // each object gets an _id field when added to the database const newValidAliases = JSON.parse( JSON.stringify(testAddressAliases.validAliasTxs), ); await addAliasesToDb(testDb, newValidAliases); // Try to add three aliases that already exists in the database const newValidAliasAlreadyInDb = JSON.parse( JSON.stringify(testAddressAliases.validAliasTxs.slice(0, 3)), ); const failedResult = await addAliasesToDb( testDb, newValidAliasAlreadyInDb, ); // Verify addAliasesToDb returned false on attempt to add duplicate aliases to the db assert.deepEqual(failedResult, false); }); }); diff --git a/apps/alias-server/test/eventsTests.js b/apps/alias-server/test/eventsTests.js index 018cbb2fa..bd16b52dc 100644 --- a/apps/alias-server/test/eventsTests.js +++ b/apps/alias-server/test/eventsTests.js @@ -1,133 +1,357 @@ // 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 assert = require('assert'); +const cashaddr = require('ecashaddrjs'); const mockSecrets = require('../secrets.sample'); -const { handleAppStartup } = require('../src/events'); +const { handleAppStartup, handleBlockConnected } = require('../src/events'); const { MockChronikClient } = require('./mocks/chronikMock'); const MockAdapter = require('axios-mock-adapter'); const axios = require('axios'); +// Mock mongodb +const { initializeDb, updateServerState } = require('../src/db'); +const { MongoClient } = require('mongodb'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { testAddressAliases } = require('./mocks/aliasMocks'); describe('alias-server events.js', async function () { + let mongoServer, testMongoClient; + 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; + beforeEach(async () => { + // Initialize db before each unit test + testDb = await initializeDb(testMongoClient); + }); + afterEach(async () => { + // Wipe the database after each unit test + await testDb.dropDatabase(); + }); it('handleAppStartup calls handleBlockConnected with tipHeight and completes function if block is avalanche finalized', async function () { // Initialize chronik mock const mockedChronik = new MockChronikClient(); const mockBlockchaininfoResponse = { tipHash: '00000000000000000ce690f27bc92c46863337cc9bd5b7c20aec094854db26e3', tipHeight: 786878, }; // Tell mockedChronik what response we expect mockedChronik.setMock('blockchainInfo', { input: null, output: mockBlockchaininfoResponse, }); + // Add tx history to mockedChronik + // Set the script + const { type, hash } = cashaddr.decode( + config.aliasConstants.registrationAddress, + true, + ); + mockedChronik.setScript(type, hash); + // Set the mock tx history + mockedChronik.setTxHistory(testAddressAliases.txHistory); + // Mock avalanche RPC call // onNoMatch: 'throwException' helps to debug if mock is not being used const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); // Mock response for rpc return of true for isfinalblock method mock.onPost().reply(200, { result: true, error: null, id: 'isfinalblock', }); - const db = null; + const db = testDb; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const result = await handleAppStartup( mockedChronik, db, telegramBot, channelId, avalancheRpc, ); assert.deepEqual( result, `Alias registrations updated to block ${mockBlockchaininfoResponse.tipHash} at height ${mockBlockchaininfoResponse.tipHeight}`, ); }); it('handleAppStartup calls handleBlockConnected with tipHeight and returns false if block is not avalanche finalized', async function () { // Initialize chronik mock const mockedChronik = new MockChronikClient(); const mockBlockchaininfoResponse = { tipHash: '00000000000000000ce690f27bc92c46863337cc9bd5b7c20aec094854db26e3', tipHeight: 786878, }; // Tell mockedChronik what response we expect mockedChronik.setMock('blockchainInfo', { input: null, output: mockBlockchaininfoResponse, }); // Mock avalanche RPC call // onNoMatch: 'throwException' helps to debug if mock is not being used const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); // Mock response for rpc return of true for isfinalblock method mock.onPost().reply(200, { result: false, error: null, id: 'isfinalblock', }); const db = null; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const result = await handleAppStartup( mockedChronik, db, telegramBot, channelId, avalancheRpc, ); assert.deepEqual(result, false); }); it('handleAppStartup returns false on chronik error', async function () { // Initialize chronik mock const mockedChronik = new MockChronikClient(); // Response of bad format const mockBlockchaininfoResponse = { tipHashNotHere: '00000000000000000ce690f27bc92c46863337cc9bd5b7c20aec094854db26e3', tipHeightNotHere: 786878, }; // Tell mockedChronik what response we expect mockedChronik.setMock('blockchainInfo', { input: null, output: mockBlockchaininfoResponse, }); // Function will not get to RPC call, no need for axios mock const db = null; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const result = await handleAppStartup( mockedChronik, db, telegramBot, channelId, avalancheRpc, ); + assert.deepEqual(result, false); + }); + it('handleBlockConnected returns false if the function fails to obtain serverState', async function () { + // tipHash called with + const tipHash = + '00000000000000000b0519ddbffcf6dbab212b95207e398ae3ed2ba312fa561d'; + + // Initialize chronik mock + const mockedChronik = new MockChronikClient(); + + const mockBlock = { + blockInfo: { + height: 783136, + }, + }; + + // Tell mockedChronik what response we expect + mockedChronik.setMock('block', { + input: tipHash, + output: mockBlock, + }); + + // Add tx history to mockedChronik + // Set the script + const { type, hash } = cashaddr.decode( + config.aliasConstants.registrationAddress, + true, + ); + mockedChronik.setScript(type, hash); + // Set the mock tx history + mockedChronik.setTxHistory(testAddressAliases.txHistory); + + // Mock avalanche RPC call + // onNoMatch: 'throwException' helps to debug if mock is not being used + const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); + // Mock response for rpc return of true for isfinalblock method + mock.onPost().reply(200, { + result: true, + error: null, + id: 'isfinalblock', + }); + + const telegramBot = null; + const channelId = null; + const { avalancheRpc } = mockSecrets; + + // Rename the serverState collection + await testDb + .collection(config.database.collections.serverState) + .rename('notTheSameName'); + + const result = await handleBlockConnected( + mockedChronik, + testDb, + telegramBot, + channelId, + avalancheRpc, + tipHash, + ); + + assert.deepEqual(result, false); + }); + it('handleBlockConnected returns false if called with a block of height lower than serverState', async function () { + // tipHash called with + const tipHash = + '00000000000000000b0519ddbffcf6dbab212b95207e398ae3ed2ba312fa561d'; + + // Initialize chronik mock + const mockedChronik = new MockChronikClient(); + + const mockBlock = { + blockInfo: { + height: 783136, + }, + }; + + // Tell mockedChronik what response we expect + mockedChronik.setMock('block', { + input: tipHash, + output: mockBlock, + }); + + // Add tx history to mockedChronik + // Set the script + const { type, hash } = cashaddr.decode( + config.aliasConstants.registrationAddress, + true, + ); + mockedChronik.setScript(type, hash); + // Set the mock tx history + mockedChronik.setTxHistory(testAddressAliases.txHistory); + + // Mock avalanche RPC call + // onNoMatch: 'throwException' helps to debug if mock is not being used + const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); + // Mock response for rpc return of true for isfinalblock method + mock.onPost().reply(200, { + result: true, + error: null, + id: 'isfinalblock', + }); + + const telegramBot = null; + const channelId = null; + const { avalancheRpc } = mockSecrets; + + // Give the app a serverState in the future of the calling block + const mockServerState = { + processedBlockheight: 783137, + processedConfirmedTxs: 100, + }; + await updateServerState(testDb, mockServerState); + + const result = await handleBlockConnected( + mockedChronik, + testDb, + telegramBot, + channelId, + avalancheRpc, + tipHash, + ); + + assert.deepEqual(result, false); + }); + it('handleBlockConnected returns false if called with a block of height equal to serverState', async function () { + // tipHash called with + const tipHash = + '00000000000000000b0519ddbffcf6dbab212b95207e398ae3ed2ba312fa561d'; + + // Initialize chronik mock + const mockedChronik = new MockChronikClient(); + + const mockBlock = { + blockInfo: { + height: 783136, + }, + }; + + // Tell mockedChronik what response we expect + mockedChronik.setMock('block', { + input: tipHash, + output: mockBlock, + }); + + // Add tx history to mockedChronik + // Set the script + const { type, hash } = cashaddr.decode( + config.aliasConstants.registrationAddress, + true, + ); + mockedChronik.setScript(type, hash); + // Set the mock tx history + mockedChronik.setTxHistory(testAddressAliases.txHistory); + + // Mock avalanche RPC call + // onNoMatch: 'throwException' helps to debug if mock is not being used + const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); + // Mock response for rpc return of true for isfinalblock method + mock.onPost().reply(200, { + result: true, + error: null, + id: 'isfinalblock', + }); + + const telegramBot = null; + const channelId = null; + const { avalancheRpc } = mockSecrets; + + // Give the app a serverState in the future of the calling block + const mockServerState = { + processedBlockheight: 783136, + processedConfirmedTxs: 100, + }; + await updateServerState(testDb, mockServerState); + + const result = await handleBlockConnected( + mockedChronik, + testDb, + telegramBot, + channelId, + avalancheRpc, + tipHash, + ); + assert.deepEqual(result, false); }); }); diff --git a/apps/alias-server/test/mainTests.js b/apps/alias-server/test/mainTests.js index 5350656e0..db59a3bf3 100644 --- a/apps/alias-server/test/mainTests.js +++ b/apps/alias-server/test/mainTests.js @@ -1,86 +1,97 @@ // 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 assert = require('assert'); const cashaddr = require('ecashaddrjs'); const { main } = require('../src/main'); const config = require('../config'); const mockSecrets = require('../secrets.sample'); const MockAdapter = require('axios-mock-adapter'); const axios = require('axios'); +const { testAddressAliases } = require('./mocks/aliasMocks'); // Mock mongodb const { MongoClient } = require('mongodb'); const { MongoMemoryServer } = require('mongodb-memory-server'); // Mock chronik const { MockChronikClient } = require('./mocks/chronikMock'); -let mongoServer, testMongoClient; -before(async () => { - mongoServer = await MongoMemoryServer.create(); - const mongoUri = mongoServer.getUri(); - testMongoClient = new MongoClient(mongoUri); -}); +describe('alias-server main.js', async function () { + let mongoServer, testMongoClient; + before(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + testMongoClient = new MongoClient(mongoUri); + }); -after(async () => { - await testMongoClient.close(); - await mongoServer.stop(); -}); + after(async () => { + await testMongoClient.close(); + await mongoServer.stop(); + }); -describe('alias-server main.js', async function () { it('main() intializes database correctly, connects to a websocket, and runs handleAppStartup() correctly', async function () { - // Define params - const mongoClient = testMongoClient; // Initialize chronik mock const mockedChronik = new MockChronikClient(); const mockBlockchaininfoResponse = { tipHash: '00000000000000000ce690f27bc92c46863337cc9bd5b7c20aec094854db26e3', tipHeight: 786878, }; // Tell mockedChronik what response we expect mockedChronik.setMock('blockchainInfo', { input: null, output: mockBlockchaininfoResponse, }); + // Add tx history to mockedChronik + // Set the script + const { type, hash } = cashaddr.decode( + config.aliasConstants.registrationAddress, + true, + ); + mockedChronik.setScript(type, hash); + // Set the mock tx history + mockedChronik.setTxHistory(testAddressAliases.txHistory); + // Mock avalanche RPC call // onNoMatch: 'throwException' helps to debug if mock is not being used const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); // Mock response for rpc return of true for isfinalblock method mock.onPost().reply(200, { result: true, error: null, id: 'isfinalblock', }); + + // Define params + const mongoClient = testMongoClient; const chronik = mockedChronik; const address = config.aliasConstants.registrationAddress; const telegramBot = null; const channelId = null; const { avalancheRpc } = mockSecrets; const returnMocks = true; const result = await main( mongoClient, chronik, address, telegramBot, channelId, avalancheRpc, returnMocks, ); - const { type, hash } = cashaddr.decode(address, true); // Check that the database was initialized properly assert.strictEqual(result.db.namespace, config.database.name); // Check that websocket is connected assert.deepEqual(result.aliasWebsocket._subs, [ { scriptPayload: hash, scriptType: type }, ]); // Check that startup was called assert.deepEqual( result.appStartup, `Alias registrations updated to block ${mockBlockchaininfoResponse.tipHash} at height ${mockBlockchaininfoResponse.tipHeight}`, ); }); });