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
9 changes: 5 additions & 4 deletions packages/btcindexer/db/migrations/0001_initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions packages/btcindexer/src/btcindexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const createMockStmt = () => ({
function mkMockD1() {
return {
prepare: vi.fn().mockImplementation(() => createMockStmt()),
batch: vi.fn().mockResolvedValue({ success: true }),
};
}

Expand Down Expand Up @@ -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.",
);
});
});
51 changes: 43 additions & 8 deletions packages/btcindexer/src/btcindexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
),
);
}
Expand Down Expand Up @@ -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 = ?`,
);
Expand Down Expand Up @@ -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 = ?`,
Expand All @@ -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 = ?`,
);
Expand Down Expand Up @@ -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 };
}
}
4 changes: 4 additions & 0 deletions packages/btcindexer/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@ export interface MintBatchArg {
txIndex: number;
proof: ProofResult;
}

export interface PostNbtcTxRequest {
txHex: string;
}
20 changes: 0 additions & 20 deletions packages/btcindexer/src/nbtc_tx.ts

This file was deleted.

20 changes: 17 additions & 3 deletions packages/btcindexer/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
};

//
Expand Down