Skip to content

Commit

Permalink
fixup! refactor(rabbitmq): now the package respects all the repositor…
Browse files Browse the repository at this point in the history
…y standards
  • Loading branch information
iccicci committed Jun 21, 2022
1 parent 7299f8f commit 60cbba9
Show file tree
Hide file tree
Showing 7 changed files with 85 additions and 68 deletions.
9 changes: 2 additions & 7 deletions packages/rabbitmq/src/TxSubmitWorker.ts
Expand Up @@ -2,19 +2,14 @@
import { Cardano, ProviderError, ProviderFailure, TxSubmitProvider } from '@cardano-sdk/core';
import { Channel, Connection, Message, connect } from 'amqplib';
import { Logger, dummyLogger } from 'ts-log';
import { TX_SUBMISSION_QUEUE, serializeError, txBodyToId, waitForPending } from './utils';
import { TX_SUBMISSION_QUEUE, serializeError, waitForPending } from './utils';

const moduleName = 'TxSubmitWorker';

/**
* Configuration options parameters for the TxSubmitWorker
*/
export interface TxSubmitWorkerConfig {
/**
* Use the algorithm to get a dummy Tx Id from a Tx body. Used for tests
*/
dummyTxId?: boolean;

/**
* Instructs the worker to process multiple transactions simultaneously.
* Default: false (serial mode)
Expand Down Expand Up @@ -270,7 +265,7 @@ export class TxSubmitWorker {
const txBody = new Uint8Array(content);

// Register the handling of current transaction
txId = this.#config.dummyTxId ? txBodyToId(txBody, true) : Cardano.util.deserializeTx(txBody).id.toString();
txId = Cardano.util.deserializeTx(txBody).id.toString();

this.#dependencies.logger!.info(`${moduleName}: submitting tx #${counter} id: ${txId}`);
this.#dependencies.logger!.debug(`${moduleName}: tx #${counter} dump:`, content.toString('hex'));
Expand Down
11 changes: 2 additions & 9 deletions packages/rabbitmq/src/rabbitmqTxSubmitProvider.ts
Expand Up @@ -2,7 +2,7 @@ import { Buffer } from 'buffer';
import { Cardano, HealthCheckResponse, ProviderError, ProviderFailure, TxSubmitProvider } from '@cardano-sdk/core';
import { Channel, Connection, connect } from 'amqplib';
import { Logger, dummyLogger } from 'ts-log';
import { TX_SUBMISSION_QUEUE, getErrorPrototype, txBodyToId, waitForPending } from './utils';
import { TX_SUBMISSION_QUEUE, getErrorPrototype, waitForPending } from './utils';
import { fromSerializableObject } from '@cardano-sdk/util';

const moduleName = 'RabbitMqTxSubmitProvider';
Expand All @@ -11,11 +11,6 @@ const moduleName = 'RabbitMqTxSubmitProvider';
* Configuration options parameters for the RabbitMqTxSubmitProvider
*/
export interface RabbitMqTxSubmitProviderConfig {
/**
* Use the algorithm to get a dummy Tx Id from a Tx body. Used for tests
*/
dummyTxId?: boolean;

/**
* The RabbitMQ connection URL
*/
Expand Down Expand Up @@ -168,9 +163,7 @@ export class RabbitMqTxSubmitProvider implements TxSubmitProvider {
};

try {
txId = this.#config.dummyTxId
? txBodyToId(signedTransaction, true)
: Cardano.util.deserializeTx(signedTransaction).id.toString();
txId = Cardano.util.deserializeTx(signedTransaction).id.toString();

this.#dependencies.logger!.info(`${moduleName}: queuing tx id: ${txId}`);

Expand Down
16 changes: 1 addition & 15 deletions packages/rabbitmq/src/utils.ts
@@ -1,4 +1,4 @@
import { CSL, Cardano, cslToCore } from '@cardano-sdk/core';
import { Cardano } from '@cardano-sdk/core';
import { OutsideOfValidityInterval } from '@cardano-ogmios/schema';
import { toSerializableObject } from '@cardano-sdk/util';

Expand Down Expand Up @@ -45,20 +45,6 @@ export const serializeError = (err: unknown) => {
return { isRetriable, serializableError };
};

export const txBodyToId = (txBody: Buffer | Uint8Array, dummy?: boolean) => {
if (dummy) {
const buffer = txBody instanceof Buffer ? txBody : Buffer.from(txBody);

return buffer.toString('hex');
}

const buffer = txBody instanceof Buffer ? txBody : Buffer.from(txBody);
const txDecoded = CSL.Transaction.from_bytes(buffer);
const txData = cslToCore.newTx(txDecoded);

return txData.id.toString();
};

// Workaround inspired to https://github.com/amqp-node/amqplib/issues/250#issuecomment-888558719
// to avoid the error reported on https://github.com/amqp-node/amqplib/issues/692
export const waitForPending = async (channel: unknown) => {
Expand Down
56 changes: 32 additions & 24 deletions packages/rabbitmq/test/TxSubmitWorker.test.ts
@@ -1,4 +1,11 @@
import { BAD_CONNECTION_URL, GOOD_CONNECTION_URL, enqueueFakeTx, removeAllQueues, testLogger } from './utils';
import {
BAD_CONNECTION_URL,
GOOD_CONNECTION_URL,
enqueueFakeTx,
removeAllQueues,
testLogger,
txsPromise
} from './utils';
import { Cardano, ProviderError, TxSubmitProvider } from '@cardano-sdk/core';
import { RabbitMqTxSubmitProvider, TxSubmitWorker } from '../src';
import {
Expand Down Expand Up @@ -111,10 +118,7 @@ describe('TxSubmitWorker', () => {
const providerClosePromise = enqueueFakeTx();

// Actually create the TxSubmitWorker
worker = new TxSubmitWorker(
{ dummyTxId: true, rabbitmqUrl: GOOD_CONNECTION_URL, ...options },
{ logger, txSubmitProvider }
);
worker = new TxSubmitWorker({ rabbitmqUrl: GOOD_CONNECTION_URL, ...options }, { logger, txSubmitProvider });
await worker.start();

await new Promise<void>((resolve) => {
Expand Down Expand Up @@ -155,15 +159,13 @@ describe('TxSubmitWorker', () => {

// Actually create the TxSubmitWorker
worker = new TxSubmitWorker(
{ dummyTxId: true, pollingCycle: 50, rabbitmqUrl: GOOD_CONNECTION_URL, ...options },
{ pollingCycle: 50, rabbitmqUrl: GOOD_CONNECTION_URL, ...options },
{ logger, txSubmitProvider }
);
await worker.start();

// Tx submission by RabbitMqTxSubmitProvider must reject with the same error got by TxSubmitWorker
await expect(enqueueFakeTx([1, 2, 3], logger)).rejects.toBeInstanceOf(
Cardano.TxSubmissionErrors.EraMismatchError
);
await expect(enqueueFakeTx(0, logger)).rejects.toBeInstanceOf(Cardano.TxSubmissionErrors.EraMismatchError);
};

it('when configured to process jobs serially', async () => performTest({ parallel: false }));
Expand All @@ -172,28 +174,33 @@ describe('TxSubmitWorker', () => {
});

it('submission is parallelized up to parallelTx Tx simultaneously', async () => {
const txs = await txsPromise;
const delays = [5, 2, 1, 3, 3, 4, 4];

mock = createMockOgmiosServer({
healthCheck: { response: { networkSynchronization: 1, success: true } },
submitTx: { response: { success: true } },
submitTxHook: async (data) => {
const txBody = Buffer.from(data!).toString('hex');
const txIdx = (() => {
for (let i = 0; i < txs.length; ++i) if (txBody === txs[i].txBodyHex) return i;
})();

// Wait 100ms * the first byte of the Tx before sending the result
// eslint-disable-next-line @typescript-eslint/no-shadow
await new Promise((resolve) => setTimeout(resolve, 100 * data![0]));
await new Promise((resolve) => setTimeout(resolve, 100 * delays[txIdx!]));
}
});

await listenPromise(mock, port);

worker = new TxSubmitWorker(
{ dummyTxId: true, parallel: true, parallelTxs: 4, rabbitmqUrl: GOOD_CONNECTION_URL },
{ parallel: true, parallelTxs: 4, rabbitmqUrl: GOOD_CONNECTION_URL },
{ logger, txSubmitProvider }
);
await worker.start();

const rabbitMqTxSubmitProvider = new RabbitMqTxSubmitProvider({
dummyTxId: true,
rabbitmqUrl: GOOD_CONNECTION_URL
});
const rabbitMqTxSubmitProvider = new RabbitMqTxSubmitProvider({ rabbitmqUrl: GOOD_CONNECTION_URL });

/*
* Tx submission plan, time sample: 100ms
Expand All @@ -206,8 +213,8 @@ describe('TxSubmitWorker', () => {
const promises: Promise<void>[] = [];
const result = [undefined, undefined, undefined, undefined, undefined, undefined, undefined];

for (const tx of [[5], [2], [1], [3, 0, 0], [3, 0, 1], [4], [4, 0]]) {
promises.push(rabbitMqTxSubmitProvider.submitTx(new Uint8Array(tx)));
for (let i = 0; i < 7; ++i) {
promises.push(rabbitMqTxSubmitProvider.submitTx(txs[i].txBodyUint8Array));
// Wait 10ms to be sure the transactions are enqueued in the right order
await new Promise((resolve) => setTimeout(resolve, 10));
}
Expand All @@ -222,17 +229,18 @@ describe('TxSubmitWorker', () => {
.filter(({ level }) => level === 'debug')
.filter(({ message }) => typeof message[0] === 'string' && message[0].match(/(tx #\d dump)|ACKing RabbitMQ/))
.map(({ message }) => message)
.map((message) => (message.length === 2 ? [message[0]] : message))
).toEqual([
['TxSubmitWorker: tx #1 dump:', '05'],
['TxSubmitWorker: tx #2 dump:', '02'],
['TxSubmitWorker: tx #3 dump:', '01'],
['TxSubmitWorker: tx #4 dump:', '030000'],
['TxSubmitWorker: tx #1 dump:'],
['TxSubmitWorker: tx #2 dump:'],
['TxSubmitWorker: tx #3 dump:'],
['TxSubmitWorker: tx #4 dump:'],
['TxSubmitWorker: ACKing RabbitMQ message #3'],
['TxSubmitWorker: tx #5 dump:', '030001'],
['TxSubmitWorker: tx #5 dump:'],
['TxSubmitWorker: ACKing RabbitMQ message #2'],
['TxSubmitWorker: tx #6 dump:', '04'],
['TxSubmitWorker: tx #6 dump:'],
['TxSubmitWorker: ACKing RabbitMQ message #4'],
['TxSubmitWorker: tx #7 dump:', '0400'],
['TxSubmitWorker: tx #7 dump:'],
['TxSubmitWorker: ACKing RabbitMQ message #5'],
['TxSubmitWorker: ACKing RabbitMQ message #1'],
['TxSubmitWorker: ACKing RabbitMQ message #6'],
Expand Down
15 changes: 8 additions & 7 deletions packages/rabbitmq/test/rabbitmqTxSubmitProvider.test.ts
@@ -1,4 +1,4 @@
import { BAD_CONNECTION_URL, GOOD_CONNECTION_URL, removeAllQueues, testLogger } from './utils';
import { BAD_CONNECTION_URL, GOOD_CONNECTION_URL, removeAllQueues, testLogger, txsPromise } from './utils';
import { ProviderError, TxSubmitProvider } from '@cardano-sdk/core';
import { RabbitMqTxSubmitProvider, TxSubmitWorker } from '../src';

Expand All @@ -23,13 +23,13 @@ describe('RabbitMqTxSubmitProvider', () => {

describe('healthCheck', () => {
it('is not ok if cannot connect', async () => {
provider = new RabbitMqTxSubmitProvider({ dummyTxId: true, rabbitmqUrl: BAD_CONNECTION_URL });
provider = new RabbitMqTxSubmitProvider({ rabbitmqUrl: BAD_CONNECTION_URL });
const res = await provider.healthCheck();
expect(res).toEqual({ ok: false });
});

it('is ok if can connect', async () => {
provider = new RabbitMqTxSubmitProvider({ dummyTxId: true, rabbitmqUrl: GOOD_CONNECTION_URL });
provider = new RabbitMqTxSubmitProvider({ rabbitmqUrl: GOOD_CONNECTION_URL });
const resA = await provider.healthCheck();
// Call again to cover the idemopotent RabbitMqTxSubmitProvider.#connectAndCreateChannel() operation
const resB = await provider.healthCheck();
Expand All @@ -42,10 +42,11 @@ describe('RabbitMqTxSubmitProvider', () => {
// eslint-disable-next-line unicorn/consistent-function-scoping
const performTest = async (rabbitmqUrl: URL) => {
try {
provider = new RabbitMqTxSubmitProvider({ dummyTxId: true, rabbitmqUrl }, { logger });
const resA = await provider.submitTx(new Uint8Array([1]));
const txs = await txsPromise;
provider = new RabbitMqTxSubmitProvider({ rabbitmqUrl }, { logger });
const resA = await provider.submitTx(txs[0].txBodyUint8Array);
// Called again to cover the idemopotent RabbitMqTxSubmitProvider.#ensureQueue() operation
const resB = await provider.submitTx(new Uint8Array([2]));
const resB = await provider.submitTx(txs[1].txBodyUint8Array);
expect(resA).toBeUndefined();
expect(resB).toBeUndefined();
} catch (error) {
Expand All @@ -55,7 +56,7 @@ describe('RabbitMqTxSubmitProvider', () => {

it('resolves if successful', async () => {
const worker = new TxSubmitWorker(
{ dummyTxId: true, parallel: true, rabbitmqUrl: GOOD_CONNECTION_URL },
{ parallel: true, rabbitmqUrl: GOOD_CONNECTION_URL },
{ logger, txSubmitProvider: { healthCheck: async () => ({ ok: true }), submitTx: () => Promise.resolve() } }
);

Expand Down
10 changes: 10 additions & 0 deletions packages/rabbitmq/test/transactions.txt
@@ -0,0 +1,10 @@
36cf5735e290a34ed8c6aeccb4bf357c9c063c05cfb629950f150f1daa093b37,83a400838258203b82f973f7e1892d56e2afd8283b64b281af2dbc51e2308f0a26d9a41e810844008258202dfc520a7f58da6fa6518912ee61974e7d8215c7ba66125d1967bccbf225d83100825820b75bb1ab212a2419396870e75df7ba755b6f8392df88a3d4c17f2fd637ffbef10001828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f42408258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a001c0227021a00029db1031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff35840379c4308ecb304e3ce5e641c53b801853cf0c0c171562644d277c4a8d90d5419e5cfff7eabff66bb765d332ab572bbbb2c35808ea8e12c5984aeb284b2f8ac04f6
57790e48c16556891ad6242958e25e2d7f399779a8c84e40c07ace2515960db0,83a400828258204c7f1f38917469d9cc9c471ccd4f8003bb31c16cb8debd1e45d43871bd7642cb0182582036950848d38b427a3024fcb10a8682bf9ae1c231efa866bbb3787261656532d90101828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f46288258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a001b70ed021a00029781031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff358409486e3d6cf4635af973b357b3d1fc0e3395cb6cc1c1c8c4450d8ca83029be269964ef04ba50a539071e2a7bffc7de789f605eeea8c95ef0fe24f048937623a01f6
e2acc56de1dc3870388034a0be69850d0c6faff4989813b59de66d01f03112a7,83a400838258207743898cd34346338d74a5fd263e67fdba988fa09c97a3fcf4c731280932896300825820a9ea85e2a588709f5ddaf4c7d0e5b80774cd93482879b1a83f20ede2130fb0e800825820fbb203291f62f17549e1ceaf3b18aff6610b03dc18875236a7dcfc4f548903330101828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f4a108258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a0029bc31021a00029db1031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff35840cb82b99a8ce8751504ef6db6eb6500d58e361f496b2d4f713f13809a93d89506e39e5919c8dbf60434e053881eb77ee504a126c4b2c2fd97b811cb5135731601f6
73b17d551ce22ca6c896d9aef88d22a172ed7c007339380935c695e3e8bc2b5e,83a4008382582036950848d38b427a3024fcb10a8682bf9ae1c231efa866bbb3787261656532d90082582049157bead84c93f78e8fb7859a89845f84a57b4381bba265da6b90fe50bcfe090082582071fb85607a1772f7795aeec4a2a888aa150f216999ef1fca7c169be39afec2840101828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f4df88258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a002622bd021a00029db1031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff358408158b2568ecc995abb9a99baa00e1e7af3411b1868e56dc6b7f9081067fa5019eef36f4bd5df65b601b539f79edf218e706ad347515ba29dec618599a93f1804f6
abeefccaae4dbcf3df1697f70eeaf05eabf05a6f140751bd3cec78adc108d242,83a40082825820e9aa21bead45e12871bf8870275f7316b36ae5472eaa0a672590436ff14e3dee01825820f13d90bb22e53fa971054b9266eb1da025fc4ce9d502558a6c19366365729b1b0101828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f51e08258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a04496c92021a00029781031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff358407f6fb5fad10200a5cb219560d136a9ffc916f49cf7eefb7b1a66675f3f47cf28dee423dcc1dce6bf67895e76bd42337c5818f9ab69dbb5bf0fd26709a0d77407f6
b40f7294d84d3933953e8afb3fcad5b962848118ff3dc10fcee4f15278dbcafd,83a40082825820fbb203291f62f17549e1ceaf3b18aff6610b03dc18875236a7dcfc4f5489033300825820af04538057f84d99d2e17100bccccfa1f609bf2eb37291f8ad13e39b4393c4e60101828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f55c88258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a041965a9021a00029781031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff3584019e15f91d1c34958e962a1440c13c9c0949d691ef123044851605fc89927cb22dceadc539978795c8a6c1f8efbd1c80fdf5445036d21c495bdc1e36c6aa5520df6
86d496bdb4afd1a70f6039db53a3114a4e59b6ea64b58aeb8da5e28a770b5aa7,83a4008382582020d89027c9fcac9eeb52b78e6147cd3984f374d3090cab8437118e72544f7c6100825820625e62f9c4d35dee021fe1313dc5229e8926e0ff4477cee0ee1a04063927f03400825820c8c7152564c233d614486c63c711ade88fdb40e6aa3eb311da667c3aaee27db30101828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f59b08258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a05287c8d021a00029db1031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff35840575ebd629ac4a05cd92eb8e73968635003683a51f8d7b315e7b6651ae110815c304e819a204a4b3b23c11348537ac198f3dddbd78a061ba8d8ddaff0e081e306f6
ab1eef06b6586a2e99127536a221b13db7e2c563df6e3b9dcf4187a600519b9c,83a40083825820c7ab9954c3ab220246ea1b26988a6b5e1230abded8d5993a1f78ce302140626800825820ed05aea9f2946df5efae196c92e6cc0b6ad577c6a87f9949370863d88a006ed000825820a1d32319ef80343501829e4b9e1104f1a879f8a0bc10b0f1bae4896da92aa5450101828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f5d988258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a0023870c021a00029db1031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff3584000c1b1f83b7869ee70010362093132e3a98915b2fcaff70fdd9be1e99353f1cdfdc71aafc0f96e039878dd02983e28075342ef2a0d8ca42d9272befc1b488b07f6
a926c72d538ed673af49a1716ecd7f0cd624fd068bdb1d2a67bedab176121844,83a40083825820a1d32319ef80343501829e4b9e1104f1a879f8a0bc10b0f1bae4896da92aa545008258207b951ddd736917fd026bf5f9dcedd422b5d8cb68031d0cf30e45ccb9c34907da008258201cb5e247d65ab72279afab04c71ee70f5b4f41ade8f58cf8c22087b454d00cef0001828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f61808258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a001c2167021a00029db1031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff358400a75ac011c26e59af1e7697381eb08d6d60160308a02b6628559f7b4730de343df08dd91198d04a28ac82e472fc0ebc04aec5aeba1501a2640ad052ee678330cf6
9580dbb57df0e160902a942aa4a03ec0090a44bf7c485b2a8fdb8be67127fbf7,83a400828258209997ab8b1ccbde633aaf99bea1e01354af0ab0a9e79b4c94ec27504aaa0476ae018258204b20cd1b0052f933bb0e2c0b85327b0b26754c15ca62b1da4c5e67ce2c20c1370001828258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a000f65688258390070e5d9e766c3b56015acb46dcf06304ce7c2346b5dff6f2724e394e662cd0789a22c3185e7a6070c051320feb59c7acd9444432ae66ef86f1a0016d70d021a00029781031a03a45925a100818258207cace81954e301836404d6ee2075be3c6809949b24f1cb2f823a93da3ce46ff35840bbd61c0164c84e9ad72bc3ce111446f409697e6cff539b834032382acbd9b6086feb6a0726d74eba1d14fa376ea91fd8d8b5f6d632223f3e20da192a8323260df6

0 comments on commit 60cbba9

Please sign in to comment.