diff --git a/README.md b/README.md index 2f6f855..35444aa 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,55 @@ Separation of contract logic and data is extremely important in the case of vers * Run normal tests with `clarinet test` -* Run chainhook tests with `clarinet test --chainhooks ./chainhooks/marketplace.chainhook.yaml ./tests_chainhook/integration_test.ts` \ No newline at end of file +* Run chainhook tests with `clarinet test --chainhooks ./chainhooks/marketplace.chainhook.yaml ./tests_chainhook/integration_test.ts` + +## How to run the POC + +* Run + +``` +> cd infrastructure && docker-compose up +``` + +This will create the infrastructure (RabbitMq, Prometheus, Grafana, Postgresql) + +* Start up the consumer + +``` +> cd chainhook-consumer && yarn start:dev +``` + +Make sure to install dependencies first of course + +* Start up the subscriber + +``` +> cd chainhook-subscriber && yarn start:dev +``` + +Make sure that you installed the dependencies first, and that you've run the migrations + +``` +> yarn migration:run +``` + +You can also drop the migrations: + +``` +> yarn schema:drop +``` + +* Run the integration test and then have a look at `http://localhost:3003/api/v1/auctionhistory/0` + +You should be getting the history of the auction entity you just inserted + +``` +[ + "Auction with the id \"0\" was created at 7/28/2023, 3:46:30 PM. The id was changed to \"10b289cf-1684-4544-b1e0-f2c3b4548b87\". The createdAt was changed to \"2023-07-28T12:46:30.742Z\". The updatedAt was changed to \"2023-07-28T09:46:30.706Z\". The hash was changed to \"5b13b317d797e65992c2064d058cc8ace0dbe490b3026b8a1a76073f02342aa1\". The auctionId was changed to \"0\". The endBlock was changed to \"1000\". The highestBid was changed to \"0\". The maker was changed to \"ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5\". The nftAsset was changed to \"ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sip009-nft\". The highestBidder was changed to \"null\". The status was changed to \"OPEN\".", + "Auction bid with the amount \"1200.00\" and bidder \"ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG\" was created at 7/28/2023, 3:46:30 PM. The id was changed to \"997d1955-349e-4a00-bfb8-6d722d0b590d\". The createdAt was changed to \"2023-07-28T12:46:30.935Z\". The updatedAt was changed to \"2023-07-28T09:46:30.883Z\". The hash was changed to \"2ea36fd6353a3b87da323c95fa71ae881c830f7ac6529600242817f9894f9603\". The amount was changed to \"1200.00\". The bidder was changed to \"ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG\".", + "Auction with the id \"0\" was updated at 7/28/2023, 3:46:31 PM. The updatedAt was changed to \"2023-07-28T12:46:30.961Z\". The hash was changed to \"95f95053620e826235584568dfd452ea5af14a9fd5aec0cb6d26b697d27c2e4d\". The highestBid was changed to \"1200.00\". The highestBidder was changed to \"ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG\".", + "Auction bid with the amount \"5000.00\" and bidder \"ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC\" was created at 7/28/2023, 3:46:31 PM. The id was changed to \"01ebce17-9dc9-4dbc-91e6-a4ad4d299711\". The createdAt was changed to \"2023-07-28T12:46:31.056Z\". The updatedAt was changed to \"2023-07-28T09:46:31.026Z\". The hash was changed to \"6a70cc66d818027a29dd5f5af4a12c225be09b6bb7d36dcb610e84194c938500\". The amount was changed to \"5000.00\". The bidder was changed to \"ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC\".", + "Auction with the id \"0\" was updated at 7/28/2023, 3:46:31 PM. The updatedAt was changed to \"2023-07-28T12:46:31.076Z\". The hash was changed to \"2f25544ed3b1e3d32a593e8193be8526f87f0923ee748a535f605771f822b963\". The highestBid was changed to \"5000.00\". The highestBidder was changed to \"ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC\".", + "Auction with the id \"0\" was updated at 7/28/2023, 3:46:31 PM. The updatedAt was changed to \"2023-07-28T12:46:31.225Z\". The hash was changed to \"1bc7cc24cc3bd580a457e26a8cb12a1738b906f1b9f1e43c99f18d6f76931006\". The status was changed to \"CANCELLED\"." +] +``` \ No newline at end of file diff --git a/chainhook-subscriber/src/common/decorators/track-changes.decorator.ts b/chainhook-subscriber/src/common/decorators/track-changes.decorator.ts new file mode 100644 index 0000000..0c64b2a --- /dev/null +++ b/chainhook-subscriber/src/common/decorators/track-changes.decorator.ts @@ -0,0 +1,15 @@ +import "reflect-metadata"; + +export const METADATA_KEY = Symbol('TrackChanges'); + +export function TrackChanges(): PropertyDecorator { + return function (target: Object, propertyKey: string | symbol): void { + let existingProperties: Array = Reflect.getMetadata(METADATA_KEY, target.constructor); + if (existingProperties) { + existingProperties.push(propertyKey); + } else { + existingProperties = [propertyKey]; + Reflect.defineMetadata(METADATA_KEY, existingProperties, target.constructor); + } + }; + } diff --git a/chainhook-subscriber/src/common/entity/custom-base.entity.ts b/chainhook-subscriber/src/common/entity/custom-base.entity.ts index b00b7bb..685880e 100644 --- a/chainhook-subscriber/src/common/entity/custom-base.entity.ts +++ b/chainhook-subscriber/src/common/entity/custom-base.entity.ts @@ -7,20 +7,28 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { TrackChanges } from '../decorators/track-changes.decorator'; /** * custom base entity */ export abstract class CustomBaseEntity extends BaseEntity { @PrimaryGeneratedColumn('uuid') + @TrackChanges() id: string; @Column({ type: 'timestamp', precision: 3, default: () => 'CURRENT_TIMESTAMP(3)' }) + @TrackChanges() createdAt: Date; @Column({ type: 'timestamp', precision: 3, default: () => 'CURRENT_TIMESTAMP(3)' }) + @TrackChanges() updatedAt: Date; + @Column({ type: 'char', length: 64 }) + @TrackChanges() + hash: string; + @BeforeInsert() createTimestamp() { this.createdAt = new Date(); diff --git a/chainhook-subscriber/src/database/migrations/1690548214447-Hash.ts b/chainhook-subscriber/src/database/migrations/1690548214447-Hash.ts new file mode 100644 index 0000000..9c27a49 --- /dev/null +++ b/chainhook-subscriber/src/database/migrations/1690548214447-Hash.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class Hash1690548214447 implements MigrationInterface { + name = 'Hash1690548214447' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "Auctions" ADD "hash" character(64) NOT NULL`); + await queryRunner.query(`ALTER TABLE "Bids" ADD "hash" character(64) NOT NULL`); + await queryRunner.query(`ALTER TABLE "auction_bids_history" ADD "hash" character(64) NOT NULL`); + await queryRunner.query(`ALTER TABLE "auctions_history" ADD "hash" character(64) NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "auctions_history" DROP COLUMN "hash"`); + await queryRunner.query(`ALTER TABLE "auction_bids_history" DROP COLUMN "hash"`); + await queryRunner.query(`ALTER TABLE "Bids" DROP COLUMN "hash"`); + await queryRunner.query(`ALTER TABLE "Auctions" DROP COLUMN "hash"`); + } + +} diff --git a/chainhook-subscriber/src/modules/auctionhistory/services/base.service.ts b/chainhook-subscriber/src/modules/auctionhistory/services/base.service.ts index 599ac01..bccf212 100644 --- a/chainhook-subscriber/src/modules/auctionhistory/services/base.service.ts +++ b/chainhook-subscriber/src/modules/auctionhistory/services/base.service.ts @@ -1,10 +1,11 @@ -import { BaseSimpleRepository } from "src/common/repository/base.simple.repository"; +import { createHash } from "crypto"; +import { METADATA_KEY } from "src/common/decorators/track-changes.decorator"; import { runOnTransactionCommit, runOnTransactionComplete, runOnTransactionRollback } from "src/common/transaction/hook"; import { BaseHistory } from "src/modules/history/entities/base.history.entity"; import * as winston from 'winston'; -export abstract class BaseService { +export abstract class BaseService { protected readonly Logger = winston.createLogger({ level: 'info', format: winston.format.combine(winston.format.timestamp(), winston.format.json()), @@ -15,30 +16,29 @@ export abstract class BaseService T, - protected history: new () => H, - protected readonly HRepository: BaseSimpleRepository, + ) { } protected setupTransactionHooks() { runOnTransactionRollback((cb) => - this.Logger.info(`[ROLLBACK] Error: ${cb.message}`), + this.Logger.error(`[ROLLBACK] Error: ${cb.message}`), ); - runOnTransactionComplete((_) => this.Logger.info('[COMMIT] Transaction Complete')); runOnTransactionCommit(() => this.Logger.info('[COMMIT] Transaction Commit')); } - protected async insertIntoHistory(oldEntity: T, newEntity: T, action: H['action'], + protected async insertIntoHistory( + historyFunc: new () => H, + oldEntity: T, newEntity: T, action: H['action'], copyEntityToHistory: (entity: T, history: H) => void, save: (history: H) => Promise) { this.Logger.info('Inside insertIntoHistory'); const entity = newEntity; - const history = new this.history(); + const history = new historyFunc(); history.action = action; history.createdAt = new Date(); @@ -51,38 +51,65 @@ export abstract class BaseService { - if (typeof value === 'object' && value !== null) { - if (cache.has(value)) { - // Duplicate reference found, discard key - return; + await save(history); + } + + protected calculateHash(entity: any): string { + const properties: Array = Reflect.getMetadata(METADATA_KEY, entity.constructor) || []; + + const obj: { [key: string]: any } = {}; + + for (const key of properties) { + if (entity.hasOwnProperty(key)) { + obj[String(key)] = entity[key]; } - // Store value in our set - cache.add(value); } - return value; - }; - - console.log(JSON.stringify(history, replacer, 2)); - - await save(history); + + const str = this.stringify(obj); + const hash = createHash('sha256'); + hash.update(str); + return hash.digest('hex'); } - buildChanges(oldEntity: T, newEntity: T): Array<{ name: string, old_value: any, new_value: any }> { + private buildChanges(oldEntity: T, newEntity: T): Array<{ name: string, old_value: any, new_value: any }> { const changes = []; - for (const key in newEntity) { - if (!oldEntity || newEntity[key] !== oldEntity[key]) { - changes.push({ - name: key, - old_value: oldEntity ? oldEntity[key] : null, - new_value: newEntity[key], - }); - } + const decoratedProperties: Array = Reflect.getMetadata(METADATA_KEY, newEntity.constructor); + + for (const key of decoratedProperties) { + // Skip if the property is not a direct property of newEntity + if (!newEntity.hasOwnProperty(key)) { + continue; + } + + if (!oldEntity || newEntity[key] !== oldEntity[key]) { + changes.push({ + name: key, + old_value: oldEntity ? oldEntity[key] : null, + new_value: newEntity[key], + }); + } } return changes; } + + private stringify(obj: any): string { + const cache = new Set(); + const replacer = (key, value) => { + if (typeof value === 'object' && value !== null) { + if (cache.has(value)) { + // Duplicate reference found, discard key + return; + } + // Store value in our set + cache.add(value); + } + return value; + }; + + return JSON.stringify(obj, replacer, 2); + } } \ No newline at end of file diff --git a/chainhook-subscriber/src/modules/auctionhistory/services/cancel.auction.service.ts b/chainhook-subscriber/src/modules/auctionhistory/services/cancel.auction.service.ts index 71970a0..bb924dc 100644 --- a/chainhook-subscriber/src/modules/auctionhistory/services/cancel.auction.service.ts +++ b/chainhook-subscriber/src/modules/auctionhistory/services/cancel.auction.service.ts @@ -13,14 +13,14 @@ import { AuctionHistoryEntityRepositoryToken } from "../providers/auction.histor import { AuctionHistoryEntityRepository } from "../repositories/auction.history.repository"; @Injectable() -export class CancelAuctionService extends BaseService { +export class CancelAuctionService extends BaseService { constructor( @Inject(AuctionEntityRepositoryToken) private auctionRepository: AuctionEntityRepository, @Inject(AuctionHistoryEntityRepositoryToken) private auctionHistoryRepository: AuctionHistoryEntityRepository, ) { - super(AuctionEntity, AuctionHistoryEntity, auctionHistoryRepository); + super(); } @Transactional({ @@ -37,15 +37,21 @@ export class CancelAuctionService extends BaseService { + await this.insertIntoHistory(AuctionHistoryEntity, oldAuction, auction, "update", (entity: AuctionEntity, history: AuctionHistoryEntity) => { history.auctionId = entity.auctionId; history.endBlock = entity.endBlock; history.createdAt = new Date(); @@ -56,6 +62,7 @@ export class CancelAuctionService extends BaseService this.auctionHistoryRepository.save(entity)); } } \ No newline at end of file diff --git a/chainhook-subscriber/src/modules/auctionhistory/services/place.bid.service.ts b/chainhook-subscriber/src/modules/auctionhistory/services/place.bid.service.ts index e7ef045..c3e0844 100644 --- a/chainhook-subscriber/src/modules/auctionhistory/services/place.bid.service.ts +++ b/chainhook-subscriber/src/modules/auctionhistory/services/place.bid.service.ts @@ -11,9 +11,13 @@ import { BaseService } from "./base.service"; import { AuctionBidHistoryEntityRepositoryToken } from "../providers/auction.bid.history.repository.provider"; import { AuctionBidHistoryEntityRepository } from "../repositories/auction.bid.history.repository"; import { AuctionBidHistoryEntity } from "../entities/auction.bid.history.entity"; +import { AuctionEntity } from "src/modules/auctions/entities/auction.entity"; +import { AuctionHistoryEntity } from "../entities/auction.history.entity"; +import { AuctionHistoryEntityRepositoryToken } from "../providers/auction.history.repository.provider"; +import { AuctionHistoryEntityRepository } from "../repositories/auction.history.repository"; @Injectable() -export class PlaceBidService extends BaseService { +export class PlaceBidService extends BaseService { constructor( @Inject(AuctionEntityRepositoryToken) @@ -22,8 +26,10 @@ export class PlaceBidService extends BaseService b.bidder === bid.bidder && b.amount == bid.amount)) { + // same bid, do nothing + this.Logger.info('Same bid, do nothing'); + return; + } await this.auctionBidRepository.save(bid); @@ -50,6 +65,7 @@ export class PlaceBidService extends BaseService { + await this.insertIntoHistory(AuctionBidHistoryEntity, null, bid, "insert", (entity: AuctionBidEntity, history: AuctionBidHistoryEntity) => { history.auctionId = auction.auctionId; history.createdAt = new Date(); history.bidder = entity.bidder; history.amount = entity.amount; - }, (entity: AuctionBidHistoryEntity) => this.HRepository.save(entity)); + history.hash = entity.hash; + }, (entity: AuctionBidHistoryEntity) => this.auctionBidHistoryRepository.save(entity)); + + await this.insertIntoHistory(AuctionHistoryEntity, oldAuction, auction, "update", (entity: AuctionEntity, history: AuctionHistoryEntity) => { + history.auctionId = entity.auctionId; + history.endBlock = entity.endBlock; + history.createdAt = new Date(); + + history.highestBid = entity.highestBid; + history.maker = entity.maker; + + history.highestBidder = entity.highestBidder; + history.nftAsset = entity.nftAsset; + history.status = entity.status; + history.hash = entity.hash; + }, (entity: AuctionHistoryEntity) => this.auctionHistoryRepository.save(entity)); this.Logger.info('Inserted into history'); diff --git a/chainhook-subscriber/src/modules/auctionhistory/services/start.auction.service.ts b/chainhook-subscriber/src/modules/auctionhistory/services/start.auction.service.ts index 71dbba8..a663649 100644 --- a/chainhook-subscriber/src/modules/auctionhistory/services/start.auction.service.ts +++ b/chainhook-subscriber/src/modules/auctionhistory/services/start.auction.service.ts @@ -12,7 +12,7 @@ import { AuctionHistoryEntityRepositoryToken } from "../providers/auction.histor import { AuctionHistoryEntityRepository } from "../repositories/auction.history.repository"; @Injectable() -export class StartAuctionService extends BaseService { +export class StartAuctionService extends BaseService { constructor( @Inject(AuctionEntityRepositoryToken) @@ -21,7 +21,7 @@ export class StartAuctionService extends BaseService { + await this.insertIntoHistory(AuctionHistoryEntity, null, auction, "insert", (entity: AuctionEntity, history: AuctionHistoryEntity) => { history.auctionId = entity.auctionId; history.endBlock = entity.endBlock; history.createdAt = new Date(); @@ -76,6 +79,7 @@ export class StartAuctionService extends BaseService this.auctionHistoryRepository.save(entity)); } } \ No newline at end of file diff --git a/chainhook-subscriber/src/modules/auctions/entities/auction.bid.entity.ts b/chainhook-subscriber/src/modules/auctions/entities/auction.bid.entity.ts index d1875ad..33aa6ad 100644 --- a/chainhook-subscriber/src/modules/auctions/entities/auction.bid.entity.ts +++ b/chainhook-subscriber/src/modules/auctions/entities/auction.bid.entity.ts @@ -3,6 +3,7 @@ import { CustomBaseEntity } from "src/common/entity/custom-base.entity"; import { Column, Entity, ManyToOne } from "typeorm"; import { AuctionEntity } from "./auction.entity"; import { Exclude } from 'class-transformer'; +import { TrackChanges } from "src/common/decorators/track-changes.decorator"; @Entity({ name: 'Bids' }) @@ -10,10 +11,12 @@ export class AuctionBidEntity extends CustomBaseEntity { @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) @Allow() + @TrackChanges() amount: number; @Column() @Allow() + @TrackChanges() bidder: string; @ManyToOne(() => AuctionEntity, auction => auction.bids, { diff --git a/chainhook-subscriber/src/modules/auctions/entities/auction.entity.ts b/chainhook-subscriber/src/modules/auctions/entities/auction.entity.ts index 7035955..af06248 100644 --- a/chainhook-subscriber/src/modules/auctions/entities/auction.entity.ts +++ b/chainhook-subscriber/src/modules/auctions/entities/auction.entity.ts @@ -8,35 +8,42 @@ import { } from 'typeorm'; import { AuctionStatus } from './auction.status'; import { AuctionBidEntity } from './auction.bid.entity'; +import { TrackChanges } from 'src/common/decorators/track-changes.decorator'; @Entity({ name: 'Auctions' }) export class AuctionEntity extends CustomBaseEntity { @Column() @Allow() + @TrackChanges() auctionId: number; @Column() @Allow() + @TrackChanges() endBlock: string; @Column({ nullable: true, }) @Allow() + @TrackChanges() highestBid: string; @Column() @Allow() + @TrackChanges() maker: string; @Column() @Allow() + @TrackChanges() nftAsset: string; @Column({ nullable: true, }) @Allow() + @TrackChanges() highestBidder: string; @Column({ @@ -44,6 +51,7 @@ export class AuctionEntity extends CustomBaseEntity { enum: AuctionStatus, default: AuctionStatus.OPEN }) + @TrackChanges() status: AuctionStatus; @OneToMany( diff --git a/chainhook-subscriber/src/modules/history/entities/base.history.entity.ts b/chainhook-subscriber/src/modules/history/entities/base.history.entity.ts index 3896156..c0f01b2 100644 --- a/chainhook-subscriber/src/modules/history/entities/base.history.entity.ts +++ b/chainhook-subscriber/src/modules/history/entities/base.history.entity.ts @@ -24,6 +24,9 @@ import { @Column('json', { default: {} }) changes: any; + @Column({ type: 'char', length: 64 }) + hash: string; + @BeforeInsert() createTimestamp() { this.createdAt = new Date();