diff --git a/packages/btcindexer/db/migrations/0001_initial_schema.sql b/packages/btcindexer/db/migrations/0001_initial_schema.sql index 918df6c6..f1e3bb87 100644 --- a/packages/btcindexer/db/migrations/0001_initial_schema.sql +++ b/packages/btcindexer/db/migrations/0001_initial_schema.sql @@ -12,15 +12,16 @@ CREATE INDEX btc_blocks_status ON btc_blocks (status); -- This table tracks the nBTC deposit txs (minting) CREATE TABLE nbtc_minting ( - tx_id TEXT PRIMARY KEY, - block_hash TEXT NOT NULL, - block_height INTEGER NOT NULL, - vout INTEGER NOT NULL, + tx_id TEXT NOT NULL, + vout INTEGER NOT NULL, + block_hash TEXT, + block_height INTEGER, sui_recipient TEXT NOT NULL, amount_sats INTEGER NOT NULL, status TEXT NOT NULL, -- 'broadcasting' | 'confirming' | 'finalized' | 'minting' | 'minted' | 'reorg' created_at INTEGER NOT NULL, -- timestamp_ms updated_at INTEGER NOT NULL, -- timestamp_ms + PRIMARY KEY (tx_id, vout) ) STRICT; CREATE INDEX nbtc_minting_status ON nbtc_txs (status); diff --git a/packages/btcindexer/src/btcindexer.test.ts b/packages/btcindexer/src/btcindexer.test.ts index cc1b8a8e..2d040f12 100644 --- a/packages/btcindexer/src/btcindexer.test.ts +++ b/packages/btcindexer/src/btcindexer.test.ts @@ -65,6 +65,7 @@ const createMockStmt = () => ({ function mkMockD1() { return { prepare: vi.fn().mockImplementation(() => createMockStmt()), + batch: vi.fn().mockResolvedValue({ success: true }), }; } @@ -264,3 +265,41 @@ describe("Block Parsing", () => { ); }); }); + +describe("Indexer.registerBroadcastedNbtcTx", () => { + it("should register a tx with a single deposit", async () => { + const { mockEnv, indexer } = prepareIndexer(); + const blockData = REGTEST_DATA[303]; + const block = Block.fromHex(blockData.rawBlockHex); + const targetTx = block.transactions?.find((tx) => tx.getId() === blockData.txs[1].id); + assert(targetTx); + + const txHex = targetTx.toHex(); + await indexer.registerBroadcastedNbtcTx(txHex); + + const insertStmt = mockEnv.DB.prepare.mock.results[0].value; + expect(mockEnv.DB.prepare).toHaveBeenCalledWith( + expect.stringContaining("INSERT OR IGNORE INTO nbtc_minting"), + ); + expect(insertStmt.bind).toHaveBeenCalledWith( + blockData.txs[1].id, + 1, // vout + blockData.txs[1].suiAddr, + blockData.txs[1].amountSats, + expect.any(Number), + expect.any(Number), + ); + }); + + it("should throw an error for a transaction with no valid deposits", async () => { + const { indexer } = prepareIndexer(); + const block = Block.fromHex(REGTEST_DATA[303].rawBlockHex); + assert(block.transactions); + // The first tx in a block is coinbase + const coinbaseTx = block.transactions[0]; + + await expect(indexer.registerBroadcastedNbtcTx(coinbaseTx.toHex())).rejects.toThrow( + "Transaction does not contain any valid nBTC deposits.", + ); + }); +}); diff --git a/packages/btcindexer/src/btcindexer.ts b/packages/btcindexer/src/btcindexer.ts index 1c9931ae..46177a9f 100644 --- a/packages/btcindexer/src/btcindexer.ts +++ b/packages/btcindexer/src/btcindexer.ts @@ -124,8 +124,15 @@ export class Indexer implements Storage { const nbtcTxStatements: D1PreparedStatement[] = []; - const insertNbtcTxStmt = this.d1.prepare( - "INSERT INTO nbtc_minting (tx_id, block_hash, block_height, vout, sui_recipient, amount_sats, status) VALUES (?, ?, ?, ?, ?, ?, ?)", + const now = Date.now(); + const insertOrUpdateNbtcTxStmt = this.d1.prepare( + `INSERT INTO nbtc_minting (tx_id, vout, block_hash, block_height, sui_recipient, amount_sats, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'confirming', ?, ?) + ON CONFLICT(tx_id, vout) DO UPDATE SET + block_hash = excluded.block_hash, + block_height = excluded.block_height, + status = 'confirming', + updated_at = excluded.updated_at`, ); for (const blockInfo of blocksToProcess.results) { @@ -142,14 +149,15 @@ export class Indexer implements Storage { const deposits = this.findNbtcDeposits(tx); for (const deposit of deposits) { nbtcTxStatements.push( - insertNbtcTxStmt.bind( + insertOrUpdateNbtcTxStmt.bind( tx.getId(), + deposit.vout, blockInfo.hash, blockInfo.height, - deposit.vout, deposit.suiRecipient, deposit.amountSats, - "confirming", + now, + now, ), ); } @@ -283,7 +291,7 @@ export class Indexer implements Storage { }); } } - const now = +new Date(); + const now = Date.now(); const setMintedStmt = this.d1.prepare( `UPDATE nbtc_minting SET status = 'minted', updated_at = ${now} WHERE tx_id = ?`, ); @@ -351,7 +359,7 @@ export class Indexer implements Storage { ): Promise<{ reorgUpdates: D1PreparedStatement[]; reorgedTxIds: string[] }> { const reorgUpdates: D1PreparedStatement[] = []; const reorgedTxIds: string[] = []; - const now = +new Date(); + const now = Date.now(); const reorgCheckStmt = this.d1.prepare("SELECT hash FROM btc_blocks WHERE height = ?"); const reorgStmt = this.d1.prepare( `UPDATE nbtc_minting SET status = 'reorg', updated_at = ${now} WHERE tx_id = ?`, @@ -377,7 +385,7 @@ export class Indexer implements Storage { selectFinalizedNbtcTxs(pendingTxs: PendingTx[], latestHeight: number): D1PreparedStatement[] { const updates: D1PreparedStatement[] = []; - const now = +new Date(); + const now = Date.now(); const finalizeStmt = this.d1.prepare( `UPDATE nbtc_minting SET status = 'finalized', updated_at = ${now} WHERE tx_id = ?`, ); @@ -446,4 +454,31 @@ export class Indexer implements Storage { }; }); } + + async registerBroadcastedNbtcTx( + txHex: string, + ): Promise<{ tx_id: string; registered_deposits: number }> { + const tx = Transaction.fromHex(txHex); + const txId = tx.getId(); + + const deposits = this.findNbtcDeposits(tx); + if (deposits.length === 0) { + throw new Error("Transaction does not contain any valid nBTC deposits."); + } + + const now = Date.now(); + const insertStmt = this.d1.prepare( + `INSERT OR IGNORE INTO nbtc_minting (tx_id, vout, sui_recipient, amount_sats, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'broadcasting', ?, ?)`, + ); + + const statements = deposits.map((deposit) => + insertStmt.bind(txId, deposit.vout, deposit.suiRecipient, deposit.amountSats, now, now), + ); + + await this.d1.batch(statements); + + console.log(`Successfully registered ${statements.length} deposit(s) for nBTC tx ${txId}.`); + return { tx_id: txId, registered_deposits: statements.length }; + } } diff --git a/packages/btcindexer/src/models.ts b/packages/btcindexer/src/models.ts index 6aafe658..3ebd3dd0 100644 --- a/packages/btcindexer/src/models.ts +++ b/packages/btcindexer/src/models.ts @@ -58,3 +58,7 @@ export interface MintBatchArg { txIndex: number; proof: ProofResult; } + +export interface PostNbtcTxRequest { + txHex: string; +} diff --git a/packages/btcindexer/src/nbtc_tx.ts b/packages/btcindexer/src/nbtc_tx.ts deleted file mode 100644 index df1622e1..00000000 --- a/packages/btcindexer/src/nbtc_tx.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type NbtcTxStatus = - | "broadcasting" - | "confirming" - | "finalized" - | "minting" - | "minted" - | "reorg"; - -export interface NbtcTx { - tx_id: string; - block_hash: string; - block_height: number; - vout: number; - sender_address: string; - sui_recipient: string; - amount_sats: number; - status: NbtcTxStatus; - created_at: string; - updated_at: string; -} diff --git a/packages/btcindexer/src/router.ts b/packages/btcindexer/src/router.ts index b0d42bda..876a6050 100644 --- a/packages/btcindexer/src/router.ts +++ b/packages/btcindexer/src/router.ts @@ -3,6 +3,7 @@ import { isValidSuiAddress } from "@mysten/sui/utils"; import { Indexer } from "./btcindexer"; import { RestPath } from "./api/client"; +import { PostNbtcTxRequest } from "./models"; import type { AppRouter, CFArgs } from "./routertype"; import { PutBlocksReq } from "./api/put-blocks"; @@ -25,7 +26,7 @@ export default class HttpRouter { }); r.put(RestPath.blocks, this.putBlocks); - r.put(RestPath.nbtcTx, this.putNbtcTx); + r.post(RestPath.nbtcTx, this.postNbtcTx); // ?sui_recipient="0x..." - query by sui address r.get(RestPath.nbtcTx, this.getStatusBySuiAddress); @@ -83,8 +84,21 @@ export default class HttpRouter { } }; - putNbtcTx = async (req: IRequest) => { - return { inserted: await this.indexer().putNbtcTx() }; + postNbtcTx = async (req: IRequest) => { + const body: PostNbtcTxRequest = await req.json(); + + if (!body || typeof body.txHex !== "string") { + return error(400, "Request body must be a JSON object with a 'txHex' property."); + } + + try { + const result = await this.indexer().registerBroadcastedNbtcTx(body.txHex); + return { success: true, ...result }; + } catch (e: unknown) { + console.error("Failed to register nBTC tx:", e); + const message = e instanceof Error ? e.message : "An unknown error occurred."; + return error(400, message); + } }; //