Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion packages/daemon/__tests__/db/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import {
updateLastSyncedEvent,
updateTxOutputSpentBy,
updateWalletLockedBalance,
updateWalletTablesWithTx
updateWalletTablesWithTx,
voidTransaction
} from '../../src/db';
import { Connection } from 'mysql2/promise';
import {
Expand All @@ -48,13 +49,15 @@ import {
addToAddressTable,
addToAddressTxHistoryTable,
addToTokenTable,
addToTransactionTable,
addToUtxoTable,
addToWalletBalanceTable,
addToWalletTable,
checkAddressBalanceTable,
checkAddressTable,
checkAddressTxHistoryTable,
checkTokenTable,
checkTransactionTable,
checkUtxoTable,
checkWalletBalanceTable,
checkWalletTxHistoryTable,
Expand All @@ -68,6 +71,8 @@ import {
import { isAuthority } from '@wallet-service/common';
import { DbTxOutput, StringMap, TokenInfo, WalletStatus } from '../../src/types';
import { Authorities, TokenBalanceMap } from '@wallet-service/common';
// @ts-ignore
import { constants } from '@hathor/wallet-lib';

// Use a single mysql connection for all tests
let mysql: Connection;
Expand Down Expand Up @@ -1164,3 +1169,95 @@ describe('getTokenSymbols', () => {
expect(tokenSymbolMap).toBeNull();
});
});

describe('voidTransaction', () => {
const txId = 'tx1';
const addr1 = 'addr1';
const token1 = 'token1';
const token2 = 'other-token';

it('should re-calculate address balances properly', async () => {
expect.hasAssertions();

await addToTransactionTable(mysql, [{
txId,
timestamp: 0,
version: constants.BLOCK_VERSION,
voided: false,
height: 1,
}]);

await addToAddressTable(mysql, [{
address: addr1,
index: 0,
walletId: null,
transactions: 2,
}]);

await addToAddressBalanceTable(mysql, [
[addr1, token1, 50, 5, null, 5, 0, 0, 100],
[addr1, token2, 25, 10, null, 4, 0, 0, 50],
]);

await addToAddressTxHistoryTable(mysql, [{
address: addr1,
txId,
tokenId: token1,
balance: 50,
timestamp: 1,
}, {
address: addr1,
txId,
tokenId: token2,
balance: 25,
timestamp: 1,
}]);

const addressBalance: StringMap<TokenBalanceMap> = {
[addr1]: TokenBalanceMap.fromStringMap({
[token1]: {
unlocked: 49,
locked: 5,
},
[token2]: {
unlocked: 24,
locked: 10,
}
}),
};

await voidTransaction(mysql, txId, addressBalance);

await expect(checkAddressBalanceTable(mysql, 2, addr1, token2, 1, 0, null, 3)).resolves.toBe(true);
await expect(checkAddressBalanceTable(mysql, 2, addr1, token1, 1, 0, null, 4)).resolves.toBe(true);
// Address tx history entry should have been deleted for both tokens:
await expect(checkAddressTxHistoryTable(mysql, 0, addr1, txId, token1, -1, 0)).resolves.toBe(true);
await expect(checkAddressTxHistoryTable(mysql, 0, addr1, txId, token2, -1, 0)).resolves.toBe(true);

await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1)).resolves.toBe(true);
});

it('should not fail when balances are empty (from a tx with no inputs and outputs)', async () => {
expect.hasAssertions();

await addToTransactionTable(mysql, [{
txId,
timestamp: 0,
version: constants.BLOCK_VERSION,
voided: false,
height: 1,
}]);

const addressBalance: StringMap<TokenBalanceMap> = {};

await expect(voidTransaction(mysql, txId, addressBalance)).resolves.not.toThrow();
// Tx should be voided
await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1)).resolves.toBe(true);
});

it('should throw an error if the transaction is not found in the database', async () => {
expect.hasAssertions();

await expect(voidTransaction(mysql, 'mysterious-transaction', {})).rejects.toThrow('Tried to void a transaction that is not in the database.');
});
});
8 changes: 8 additions & 0 deletions packages/daemon/__tests__/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export interface AddressTableEntry {
transactions: number;
}

export interface TransactionTableEntry {
txId: string;
timestamp: number;
version: number;
voided: boolean;
height: number;
}

export interface WalletBalanceEntry {
walletId: string;
tokenId: string;
Expand Down
72 changes: 70 additions & 2 deletions packages/daemon/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { Connection as MysqlConnection, RowDataPacket } from 'mysql2/promise';
import { DbTxOutput, EventTxInput } from '../src/types';
import { DbTxOutput, EventTxInput, TransactionTableRow } from '../src/types';
import { TxInput, TxOutputWithIndex } from '@wallet-service/common/src/types';
import {
AddressBalanceRow,
Expand All @@ -23,7 +23,8 @@ import {
TokenTableEntry,
WalletBalanceEntry,
WalletTableEntry,
AddressTxHistoryTableEntry
AddressTxHistoryTableEntry,
TransactionTableEntry
} from './types';
import { isEqual } from 'lodash';

Expand Down Expand Up @@ -245,6 +246,26 @@ export const addToAddressTable = async (
[payload]);
};

export const addToTransactionTable = async (
mysql: MysqlConnection,
transactions: TransactionTableEntry[],
): Promise<void> => {
const payload = transactions.map((entry) => ([
entry.txId,
entry.timestamp,
entry.version,
entry.voided,
entry.height,
]));

await mysql.query(`
INSERT INTO \`transaction\` (\`tx_id\`, \`timestamp\`,
\`version\`, \`voided\`,
\`height\`)
VALUES ?`,
[payload]);
};

export const checkAddressTable = async (
mysql: MysqlConnection,
totalResults: number,
Expand Down Expand Up @@ -289,6 +310,53 @@ export const checkAddressTable = async (
return true;
};

export const checkTransactionTable = async (
mysql: MysqlConnection,
totalResults: number,
txId: string,
timestamp: number,
version: number,
voided: boolean,
height: number,
): Promise<boolean | Record<string, unknown>> => {
// first check the total number of rows in the table
let [results] = await mysql.query<TransactionTableRow[]>('SELECT * FROM `transaction`');

if (results.length !== totalResults) {
return {
error: 'checkTransactionTable total results',
expected: totalResults,
received: results.length,
results,
};
}

if (totalResults === 0) return true;

// now fetch the exact entry

[results] = await mysql.query<TransactionTableRow[]>(`
SELECT *
FROM \`transaction\`
WHERE \`tx_id\` = ?
AND \`timestamp\` = ?
AND \`version\` = ?
AND \`voided\` = ?
AND \`height\` = ?
`, [txId, timestamp, version, voided, height],
);

if (results.length !== 1) {
return {
error: 'checkAddressTable query',
params: { txId, timestamp, version, voided, height },
results,
};
}

return true;
};

export const checkAddressBalanceTable = async (
mysql: MysqlConnection,
totalResults: number,
Expand Down
2 changes: 1 addition & 1 deletion packages/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"test_images_up": "docker-compose -f ./__tests__/integration/scripts/docker-compose.yml up -d",
"test_images_down": "docker-compose -f ./__tests__/integration/scripts/docker-compose.yml down",
"test_images_integration": "jest --config ./jest_integration.config.js --runInBand --forceExit",
"test_images_migrate": "DB_NAME=hathor DB_PORT=3380 DB_PASS=hathor DB_USER=hathor yarn run sequelize-cli --migrations-path ../../db/migrations --config ./__tests__/integration/scripts/sequelize-db-config.js db:migrate",
"test_images_migrate": "NODE_ENV=test DB_NAME=hathor DB_PORT=3380 DB_PASS=hathor DB_USER=hathor yarn run sequelize-cli --migrations-path ../../db/migrations --config ./__tests__/integration/scripts/sequelize-db-config.js db:migrate",
Comment thread
andreabadesso marked this conversation as resolved.
"test_images_wait_for_db": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-db-up.ts",
"test_images_wait_for_ws": "yarn dlx ts-node ./__tests__/integration/scripts/wait-for-ws-up.ts",
"test_images_setup_database": "yarn dlx ts-node ./__tests__/integration/scripts/setup-database.ts",
Expand Down
63 changes: 41 additions & 22 deletions packages/daemon/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import mysql, { Connection as MysqlConnection, Pool } from 'mysql2/promise';
import mysql, { Connection as MysqlConnection, OkPacket, Pool, ResultSetHeader } from 'mysql2/promise';
import {
DbTxOutput,
StringMap,
Expand Down Expand Up @@ -362,20 +362,51 @@ export const getTxOutputsAtHeight = async (
return utxos;
};

/**
* Void a transaction by updating the related address and balance information in the database.
*
* @param mysql - The MySQL connection object
* @param txId - The ID of the transaction to be voided.
* @param addressBalanceMap - A map where the key is an address and the value is a map of token balances.
* The TokenBalanceMap contains information about the total amount sent, unlocked and locked amounts, and authorities.
*
* @returns {Promise<void>} - A promise that resolves when the transaction has been voided and the database updated
*
* This function performs the following steps:
* 1. Inserts addresses with a transaction count of 0 into the `address` table or subtracts 1 from the transaction count if they already exist
* 2. Iterates over the addressBalanceMap to update the `address_balance` table with the received token balances.
* 3. Deletes the transaction entry from the `address_tx_history` table.
* 4. Updates the transaction entry in the `transaction` table to mark it as voided.
*
* The function ensures that the authorities are correctly updated and the smallest timelock expiration value is preserved.
*/
export const voidTransaction = async (
mysql: any,
txId: string,
addressBalanceMap: StringMap<TokenBalanceMap>,
): Promise<void> => {
const addressEntries = Object.keys(addressBalanceMap).map((address) => [address, 0]);
await mysql.query(
`INSERT INTO \`address\`(\`address\`, \`transactions\`)
VALUES ?
ON DUPLICATE KEY UPDATE transactions = transactions - 1`,
[addressEntries],
const [result]: [ResultSetHeader] = await mysql.query(
`UPDATE \`transaction\`
SET \`voided\` = TRUE
WHERE \`tx_id\` = ?`,
[txId],
);

const entries = [];
if (result.affectedRows !== 1) {
throw new Error('Tried to void a transaction that is not in the database.');
}

const addressEntries = Object.keys(addressBalanceMap).map((address) => [address, 0]);

if (addressEntries.length > 0) {
await mysql.query(
`INSERT INTO \`address\`(\`address\`, \`transactions\`)
VALUES ?
ON DUPLICATE KEY UPDATE transactions = transactions - 1`,
[addressEntries],
);
}

for (const [address, tokenMap] of Object.entries(addressBalanceMap)) {
for (const [token, tokenBalance] of tokenMap.iterator()) {
// update address_balance table or update balance and transactions if there's an entry already
Expand Down Expand Up @@ -438,25 +469,13 @@ export const voidTransaction = async (
// for locked authorities, it doesn't make sense to perform the same operation. The authority needs to be
// unlocked before it can be spent. In case we're just adding new locked authorities, this will be taken
// care by the first sql query.

// update address_tx_history with one entry for each pair (address, token)
entries.push(txId);
Comment thread
andreabadesso marked this conversation as resolved.
}
}

await mysql.query(
`DELETE FROM \`address_tx_history\`
WHERE \`tx_id\`
IN (?)`,
[entries],
);

await mysql.query(
`UPDATE \`transaction\`
SET \`voided\` = TRUE
WHERE \`tx_id\`
IN (?)`,
[entries],
WHERE \`tx_id\` = ?`,
[txId],
);
};

Expand Down
8 changes: 8 additions & 0 deletions packages/daemon/src/types/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export interface AddressTableRow extends RowDataPacket {
transactions: number;
}

export interface TransactionTableRow extends RowDataPacket {
tx_id: string;
timestamp: number;
version: number;
voided: boolean;
height: number;
}

export interface AddressBalanceRow extends RowDataPacket {
address: string;
token_id: string;
Expand Down