diff --git a/bridge/.gitignore b/bridge/.gitignore index 2b161bb..d244380 100644 --- a/bridge/.gitignore +++ b/bridge/.gitignore @@ -1,4 +1,3 @@ - # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,node # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,node @@ -141,4 +140,8 @@ temp/ .env -# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,node \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,node + +# macOS system files +.DS_Store +**/.DS_Store diff --git a/bridge/bridge.jest.config.js b/bridge/bridge.jest.config.js index aa3c384..87cce67 100644 --- a/bridge/bridge.jest.config.js +++ b/bridge/bridge.jest.config.js @@ -1,16 +1,16 @@ module.exports = { - name: "bridge", - displayName: "bridge", + name: "bridge", + displayName: "bridge", - // NOTE: if you don't set this correctly then when you reference - // it later in a path string you'll get a confusing error message. - // It says something like' Module /config/polyfills.js in - // the setupFiles option was not found.' - rootDir: "./../", - testPathIgnorePatterns: ["/node_modules/", "/dist/", "/test/aws-dependent"], - testMatch: ["**/*/*.spec.{ts,tsx}"], - coverageDirectory: "coverage", - coverageReporters: ["lcov"], + // NOTE: if you don't set this correctly then when you reference + // it later in a path string you'll get a confusing error message. + // It says something like' Module /config/polyfills.js in + // the setupFiles option was not found.' + rootDir: "./../", + testPathIgnorePatterns: ["/node_modules/", "/dist/", "/test/aws-dependent"], + testMatch: ["**/*/*.spec.{ts,tsx}"], + coverageDirectory: "./bridge/coverage", + coverageReporters: ["lcov"], - // etc... + // etc... }; diff --git a/bridge/src/index.ts b/bridge/src/index.ts index eb7a329..b5efaa4 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -30,6 +30,7 @@ import { SpreadsheetClient } from "./spreadsheet-client"; import { google } from "googleapis"; import { MultiPlanetary } from "./multi-planetary"; import { bscBridgeContractAbi } from "./bsc-bridge-contract-abi"; +import { PendingTransactionHandler } from "./pending-transactions"; consoleStamp(console); @@ -336,6 +337,16 @@ process.on("uncaughtException", console.error); }; const multiPlanetary = new MultiPlanetary(planetIds, planetVaultAddress); + const pendingTransactionRetryHandler = new PendingTransactionHandler( + exchangeHistoryStore, + ncgKmsTransfer, + multiPlanetary, + slackMessageSender + ); + + // 서버 시작 시 pending 트랜잭션 slack 메시지 전송 + await pendingTransactionRetryHandler.messagePendingTransactions(); + const ethereumBurnEventObserver = new BscBurnEventObserver( ncgKmsTransfer, slackMessageSender, diff --git a/bridge/src/interfaces/exchange-history-store.ts b/bridge/src/interfaces/exchange-history-store.ts index 3bfc25a..178aac1 100644 --- a/bridge/src/interfaces/exchange-history-store.ts +++ b/bridge/src/interfaces/exchange-history-store.ts @@ -1,3 +1,5 @@ +import { TransactionStatus } from "../types/transaction-status"; + export interface ExchangeHistory { network: string; tx_id: string; @@ -5,14 +7,21 @@ export interface ExchangeHistory { recipient: string; timestamp: string; amount: number; + status: TransactionStatus; } export interface IExchangeHistoryStore { put(history: ExchangeHistory): Promise; exist(tx_id: string): Promise; + updateStatus( + tx_id: string, + status: TransactionStatus.COMPLETED | TransactionStatus.FAILED + ): Promise; transferredAmountInLast24Hours( network: string, sender: string ): Promise; + + getPendingTransactions(): Promise; } diff --git a/bridge/src/messages/pending-transaction-message.ts b/bridge/src/messages/pending-transaction-message.ts new file mode 100644 index 0000000..226d6b4 --- /dev/null +++ b/bridge/src/messages/pending-transaction-message.ts @@ -0,0 +1,69 @@ +import { ChatPostMessageArguments } from "@slack/web-api"; +import { Message } from "."; +import { ForceOmit } from "../types/force-omit"; +import { ExchangeHistory } from "../interfaces/exchange-history-store"; +import { combineUrl } from "./utils"; +import { MultiPlanetary } from "../multi-planetary"; + +export class PendingTransactionMessage implements Message { + private readonly _bscScanUrl: string; + private readonly _multiPlanetary: MultiPlanetary; + + constructor( + private readonly transactions: ExchangeHistory[], + private readonly multiPlanetary: MultiPlanetary, + bscScanUrl: string = process.env.BSCSCAN_URL || "https://bscscan.com" + ) { + this._bscScanUrl = bscScanUrl; + this._multiPlanetary = multiPlanetary; + } + + render(): ForceOmit, "channel"> { + if (this.transactions) { + console.log("Pending Transactions : ", this.transactions); + return { + text: `${this.transactions.length} Pending Transactions Found`, + attachments: this.transactions.map((tx) => ({ + author_name: "[BSC] wNCG → NCG pending event", + color: "#ff0033", + fields: [ + { + title: "BSC transaction", + value: combineUrl(this._bscScanUrl, `/tx/${tx.tx_id}`), + }, + { + title: "Planet Name", + value: this._multiPlanetary.getRequestPlanetName(tx.recipient), + }, + { + title: "Sender(BSC)", + value: tx.sender, + }, + { + title: "Recipient(9c)", + value: tx.recipient, + }, + { + title: "Amount", + value: tx.amount.toString(), + }, + { + title: "Timestamp", + value: tx.timestamp, + }, + ], + })), + }; + } + + return { + text: "No pending transactions", + attachments: [ + { + author_name: "BSC Bridge Restarted", + color: "#ffcc00", + }, + ], + }; + } +} diff --git a/bridge/src/observers/burn-event-observer.ts b/bridge/src/observers/burn-event-observer.ts index 1a31179..4d3dc99 100644 --- a/bridge/src/observers/burn-event-observer.ts +++ b/bridge/src/observers/burn-event-observer.ts @@ -15,6 +15,7 @@ import { IExchangeHistoryStore } from "../interfaces/exchange-history-store"; import { UnwrappingRetryIgnoreEvent } from "../messages/unwrapping-retry-ignore-event"; import { SpreadsheetClient } from "../spreadsheet-client"; import { MultiPlanetary } from "../multi-planetary"; +import { TransactionStatus } from "../types/transaction-status"; export class BscBurnEventObserver implements @@ -138,6 +139,7 @@ export class BscBurnEventObserver recipient: user9cAddress, timestamp: new Date().toISOString(), amount: parseFloat(amountString), + status: TransactionStatus.PENDING, }); try { @@ -191,6 +193,10 @@ export class BscBurnEventObserver requestPlanetName ) ); + await this._exchangeHistoryStore.updateStatus( + transactionHash, + TransactionStatus.COMPLETED + ); await this._opensearchClient.to_opensearch("info", { content: "wNCG -> NCG request success", libplanetTxId: nineChroniclesTxId, @@ -226,6 +232,11 @@ export class BscBurnEventObserver ) ); + await this._exchangeHistoryStore.updateStatus( + transactionHash, + TransactionStatus.FAILED + ); + await this._spreadsheetClient.to_spreadsheet_burn({ slackMessageId: `${slackMsgRes?.channel}/p${slackMsgRes?.ts?.replace( ".", diff --git a/bridge/src/pending-transactions.ts b/bridge/src/pending-transactions.ts new file mode 100644 index 0000000..2be34f1 --- /dev/null +++ b/bridge/src/pending-transactions.ts @@ -0,0 +1,33 @@ +import { IExchangeHistoryStore } from "./interfaces/exchange-history-store"; +import { INCGTransfer } from "./interfaces/ncg-transfer"; +import { MultiPlanetary } from "./multi-planetary"; +import { ISlackMessageSender } from "./interfaces/slack-message-sender"; +import { PendingTransactionMessage } from "./messages/pending-transaction-message"; +import { TransactionStatus } from "./types/transaction-status"; + +export class PendingTransactionHandler { + constructor( + private readonly _exchangeHistoryStore: IExchangeHistoryStore, + private readonly _ncgTransfer: INCGTransfer, + private readonly _multiPlanetary: MultiPlanetary, + private readonly _slackMessageSender: ISlackMessageSender + ) {} + + async messagePendingTransactions(): Promise { + const pendingTransactions = + await this._exchangeHistoryStore.getPendingTransactions(); + + if (pendingTransactions.length > 0) { + await this._slackMessageSender.sendMessage( + new PendingTransactionMessage(pendingTransactions, this._multiPlanetary) + ); + + for (const tx of pendingTransactions) { + await this._exchangeHistoryStore.updateStatus( + tx.tx_id, + TransactionStatus.FAILED + ); + } + } + } +} diff --git a/bridge/src/sqlite3-exchange-history-store.ts b/bridge/src/sqlite3-exchange-history-store.ts index 04af875..028376c 100644 --- a/bridge/src/sqlite3-exchange-history-store.ts +++ b/bridge/src/sqlite3-exchange-history-store.ts @@ -4,6 +4,7 @@ import { } from "./interfaces/exchange-history-store"; import { Database } from "sqlite3"; import { promisify } from "util"; +import { TransactionStatus } from "./types/transaction-status"; export class Sqlite3ExchangeHistoryStore implements IExchangeHistoryStore { private readonly _database: Database; @@ -16,14 +17,15 @@ export class Sqlite3ExchangeHistoryStore implements IExchangeHistoryStore { put(history: ExchangeHistory): Promise { this.checkClosed(); - const { network, tx_id, sender, recipient, amount, timestamp } = history; + const { network, tx_id, sender, recipient, amount, timestamp, status } = + history; const run: (sql: string, params: any[]) => Promise = promisify( this._database.run.bind(this._database) ); return run( - "INSERT INTO exchange_histories(network, tx_id, sender, recipient, amount, timestamp) VALUES (?, ?, ?, ?, ?, ?)", - [network, tx_id, sender, recipient, amount, timestamp] + "INSERT INTO exchange_histories(network, tx_id, sender, recipient, amount, timestamp, status) VALUES (?, ?, ?, ?, ?, ?, ?)", + [network, tx_id, sender, recipient, amount, timestamp, status] ); } @@ -65,6 +67,7 @@ export class Sqlite3ExchangeHistoryStore implements IExchangeHistoryStore { static async open(path: string): Promise { const database = new Database(path); await this.initialize(database); + await this.ensureStatusColumn(database); return new Sqlite3ExchangeHistoryStore(database); } @@ -76,6 +79,7 @@ export class Sqlite3ExchangeHistoryStore implements IExchangeHistoryStore { recipient TEXT NOT NULL, amount TEXT NOT NULL, timestamp DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT '${TransactionStatus.PENDING}', PRIMARY KEY(network, tx_id) ); CREATE INDEX IF NOT EXISTS exchange_history_idx ON exchange_histories(sender);`; @@ -89,7 +93,29 @@ export class Sqlite3ExchangeHistoryStore implements IExchangeHistoryStore { }); }); } + /** 운영테이블에 status 컬럼 추가후 삭제될 코드 START */ + private static async ensureStatusColumn(database: Database): Promise { + interface ColumnInfo { + name: string; + } + + const columns = (await promisify(database.all.bind(database))( + "PRAGMA table_info(exchange_histories)" + )) as ColumnInfo[]; + + const hasStatusColumn = columns.some((col) => col.name === "status"); + if (!hasStatusColumn) { + await promisify(database.run.bind(database))( + `ALTER TABLE exchange_histories ADD COLUMN status TEXT DEFAULT '${TransactionStatus.PENDING}'` + ); + + await promisify(database.run.bind(database))( + `UPDATE exchange_histories SET status = '${TransactionStatus.COMPLETED}' WHERE status IS NULL` + ); + } + } + /** 운영테이블에 status 컬럼 추가후 삭제될 코드 END */ close(): void { this.checkClosed(); @@ -102,4 +128,31 @@ export class Sqlite3ExchangeHistoryStore implements IExchangeHistoryStore { throw new Error("This internal SQLite3 database is already closed."); } } + + async updateStatus( + tx_id: string, + status: TransactionStatus.COMPLETED | TransactionStatus.FAILED + ): Promise { + this.checkClosed(); + + const run: (sql: string, params: any[]) => Promise = promisify( + this._database.run.bind(this._database) + ); + return run("UPDATE exchange_histories SET status = ? WHERE tx_id = ?", [ + status, + tx_id, + ]); + } + + async getPendingTransactions(): Promise { + this.checkClosed(); + + const all: (sql: string, params: any[]) => Promise = + promisify(this._database.all.bind(this._database)); + + return await all( + `SELECT * FROM exchange_histories WHERE status = '${TransactionStatus.PENDING}'`, + [] + ); + } } diff --git a/bridge/src/types/transaction-status.ts b/bridge/src/types/transaction-status.ts new file mode 100644 index 0000000..b9e55f3 --- /dev/null +++ b/bridge/src/types/transaction-status.ts @@ -0,0 +1,5 @@ +export enum TransactionStatus { + PENDING = "pending", + COMPLETED = "completed", + FAILED = "failed", +} diff --git a/bridge/test/observers/burn-event-observer.spec.ts b/bridge/test/observers/burn-event-observer.spec.ts index daebf17..b154919 100644 --- a/bridge/test/observers/burn-event-observer.spec.ts +++ b/bridge/test/observers/burn-event-observer.spec.ts @@ -90,6 +90,8 @@ describe(BscBurnEventObserver.name, () => { put: jest.fn(), exist: jest.fn(), transferredAmountInLast24Hours: jest.fn(), + updateStatus: jest.fn(), + getPendingTransactions: jest.fn(), }; const mockIntegration: jest.Mocked = { @@ -228,6 +230,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 1, + status: "pending", }, ], [ @@ -238,6 +241,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 1.2, + status: "pending", }, ], [ @@ -248,6 +252,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 0.01, + status: "pending", }, ], [ @@ -258,6 +263,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 3.22, + status: "pending", }, ], ]); @@ -312,6 +318,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 1, + status: "pending", }, ], [ @@ -322,6 +329,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 1.2, + status: "pending", }, ], [ @@ -332,6 +340,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 0.01, + status: "pending", }, ], [ @@ -342,6 +351,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 3.22, + status: "pending", }, ], ]); @@ -406,6 +416,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 1, + status: "pending", }, ], [ @@ -416,6 +427,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 1.2, + status: "pending", }, ], [ @@ -426,6 +438,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 0.01, + status: "pending", }, ], [ @@ -436,6 +449,7 @@ describe(BscBurnEventObserver.name, () => { recipient: ncgRecipient, timestamp: expect.any(String), amount: 3.22, + status: "pending", }, ], ]); diff --git a/bridge/test/sqlite3-exchange-history-store.spec.ts b/bridge/test/sqlite3-exchange-history-store.spec.ts index 108dbaf..92d0b85 100644 --- a/bridge/test/sqlite3-exchange-history-store.spec.ts +++ b/bridge/test/sqlite3-exchange-history-store.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { promises } from "fs"; import { ExchangeHistory } from "../src/interfaces/exchange-history-store"; +import { TransactionStatus } from "../src/types/transaction-status"; describe("Sqlite3ExchangeHistoryStore", () => { let store: Sqlite3ExchangeHistoryStore; @@ -33,6 +34,7 @@ describe("Sqlite3ExchangeHistoryStore", () => { sender: "ADDRESS", timestamp: "timestamp", tx_id: "TX-ID", + status: TransactionStatus.PENDING, }); expect(await store.exist("TX-ID")).toBeTruthy(); @@ -62,6 +64,7 @@ describe("Sqlite3ExchangeHistoryStore", () => { recipient: "0x6d29f9923C86294363e59BAaA46FcBc37Ee5aE2e", timestamp: new Date().toISOString(), amount, + status: TransactionStatus.PENDING, }; }); @@ -91,6 +94,7 @@ describe("Sqlite3ExchangeHistoryStore", () => { recipient, timestamp: new Date().toISOString(), amount, + status: TransactionStatus.PENDING, }; }); @@ -103,6 +107,7 @@ describe("Sqlite3ExchangeHistoryStore", () => { new Date().getTime() - 24 * 60 * 60 * 1000 ).toISOString(), amount: 1000, + status: TransactionStatus.PENDING, }); for (const history of histories) { @@ -117,6 +122,45 @@ describe("Sqlite3ExchangeHistoryStore", () => { }); }); + describe("transaction status management", () => { + it("should manage transaction status correctly", async () => { + // 1. 초기 상태 저장 + const tx: ExchangeHistory = { + network: "bsc", + tx_id: "TX-STATUS-TEST", + sender: "0x2734048eC2892d111b4fbAB224400847544FC872", + recipient: "0x6d29f9923C86294363e59BAaA46FcBc37Ee5aE2e", + timestamp: new Date().toISOString(), + amount: 1.0, + status: TransactionStatus.PENDING, + }; + await store.put(tx); + + // 저장 직후 데이터 확인 + let checkAfterPut = await (store as any)._database.all( + "SELECT * FROM exchange_histories WHERE tx_id = ?", + ["TX-STATUS-TEST"] + ); + console.log("After PUT:", checkAfterPut); + + // status 업데이트 후 데이터 확인 + await store.updateStatus("TX-STATUS-TEST", TransactionStatus.FAILED); + let checkAfterUpdate = await (store as any)._database.all( + "SELECT * FROM exchange_histories WHERE tx_id = ?", + ["TX-STATUS-TEST"] + ); + console.log("After UPDATE:", checkAfterUpdate); + }); + + it("should handle non-existent transaction", async () => { + // 존재하지 않는 트랜잭션의 상태 업데이트 + await store.updateStatus("NON-EXISTENT-TX", TransactionStatus.COMPLETED); + + const pendingTxs = await store.getPendingTransactions(); + expect(pendingTxs).toHaveLength(0); + }); + }); + it("should throw error.", () => { expect(() => store.close()).not.toThrowError(); expect(() => store.close()).toThrowError();