Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2,486 changes: 2,122 additions & 364 deletions packages/block-ingestor/worker-configuration.d.ts

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions packages/btcindexer/src/btc-address-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Transaction, payments, script } from "bitcoinjs-lib";
import { logError } from "@gonative-cc/lib/logger";
import { BtcNet } from "@gonative-cc/lib/nbtc";
import { btcNetworkCfg } from "./models";
import type { Input } from "bitcoinjs-lib/src/transaction";

function extractAddressFromInput(input: Input, btcNetwork: BtcNet): string | null {
try {
const network = btcNetworkCfg[btcNetwork];

if (input.witness && input.witness.length >= 2) {
const pubKey = input.witness[1];
const { address } = payments.p2wpkh({ pubkey: pubKey, network });
return address || null;
}

const scriptSig = input?.script;
if (scriptSig && scriptSig.length >= 65) {
const chunks = script.decompile(scriptSig);
if (!chunks) return null;

const pubKey = chunks[chunks.length - 1] as Buffer;
const { address } = payments.p2pkh({ pubkey: pubKey, network });
return address || null;
}

return null;
} catch (e) {
logError({ method: "extractAddressFromInput", msg: "Failed to extract address" }, e);
return null;
}
}

export async function extractSenderAddresses(
tx: Transaction,
btcNetwork: BtcNet,
): Promise<string[]> {
if (tx.ins.length === 0) return [];

const addresses: string[] = [];
for (const input of tx.ins) {
const addr = extractAddressFromInput(input, btcNetwork);
if (addr) addresses.push(addr);
}
return addresses;
}
6 changes: 6 additions & 0 deletions packages/btcindexer/src/btcindexer.helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type SuiIndexerRpc,
RedeemRequestStatus,
type FinalizeRedeemTx,
type ComplianceRpc,
} from "@gonative-cc/lib/rpc-types";
import { dropTables, initDb } from "@gonative-cc/lib/test-helpers/init_db";

Expand Down Expand Up @@ -194,6 +195,10 @@ export async function setupTestIndexerSuite(
indexerStorage.updateRedeemStatuses(redeemIds, status),
} as unknown as Service<SuiIndexerRpc & WorkerEntrypoint>;

const mockComplianceService = {
isBtcBlocked: (_address: string) => Promise.resolve(false),
} as unknown as Service<ComplianceRpc & WorkerEntrypoint>;

const indexer = new Indexer(
storage,
[packageConfig],
Expand All @@ -203,6 +208,7 @@ export async function setupTestIndexerSuite(
options.maxRetries || 2,
electrsClients,
mockSuiIndexerService,
mockComplianceService,
);

//
Expand Down
31 changes: 31 additions & 0 deletions packages/btcindexer/src/btcindexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,37 @@ describe("Indexer.processFinalizedTransactions", () => {
});
});

describe("Indexer.processFinalizedTransactions Sanctions Filtering", () => {
it("should skip sanctioned addresses and not mint", async () => {
const txData = REGTEST_DATA[329]!.txs[1]!;

await suite.insertTx({ txId: txData.id, status: MintTxStatus.Finalized });
await suite.setupBlock(329);

// Mock compliance to block all addresses
const mockIsBtcBlocked = jest.fn().mockImplementation((addresses: string[]) => {
const result: Record<string, boolean> = {};
for (const addr of addresses) {
result[addr] = true; // Block all addresses
}
return Promise.resolve(result);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
indexer.compliance = { isBtcBlocked: mockIsBtcBlocked } as any;

await indexer.processFinalizedTransactions();

// Verify compliance was called
expect(mockIsBtcBlocked).toHaveBeenCalled();

// Verify mint was never called (transaction was filtered out)
expect(suite.mockSuiClient.tryMintNbtcBatch).not.toHaveBeenCalled();

// Verify transaction status remains finalized (not updated since batch was empty)
await suite.expectTxStatus(txData.id, MintTxStatus.Finalized);
});
});

describe("Indexer.processFinalizedTransactions Retry Logic", () => {
it("should retry a failed tx and succeed", async () => {
const txData = REGTEST_DATA[329]!.txs[1]!;
Expand Down
87 changes: 74 additions & 13 deletions packages/btcindexer/src/btcindexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,14 @@ import type {
NbtcPkgCfg,
NbtcDepositAddrsMap,
} from "./models";
import { MintTxStatus, InsertBlockStatus } from "./models";
import { MintTxStatus, InsertBlockStatus, btcNetworkCfg } from "./models";
import type { Electrs } from "./electrs";
import { ElectrsService, ELECTRS_URLS_BY_NETWORK } from "./electrs";
import { fetchNbtcAddresses, fetchPackageConfigs, type Storage } from "./storage";
import { CFStorage } from "./cf-storage";
import type { PutNbtcTxResponse } from "./rpc-interface";

const btcNetworkCfg: Record<BtcNet, Network> = {
[BtcNet.MAINNET]: networks.bitcoin,
[BtcNet.TESTNET]: networks.testnet,
[BtcNet.REGTEST]: networks.regtest,
[BtcNet.SIGNET]: networks.testnet,
};
import { extractSenderAddresses } from "./btc-address-utils";
import type { ComplianceRpc } from "@gonative-cc/lib/rpc-types";

interface ConfirmingTxCandidate<T> {
id: string | number;
Expand Down Expand Up @@ -97,6 +92,7 @@ export async function indexerFromEnv(env: Env): Promise<Indexer> {
maxNbtcMintTxRetries,
electrsClients,
env.SuiIndexer as unknown as Service<SuiIndexerRpc & WorkerEntrypoint>,
env.Compliance as unknown as Service<ComplianceRpc & WorkerEntrypoint>,
);
} catch (err) {
logError({ msg: "Can't create btcindexer", method: "Indexer.constructor" }, err);
Expand All @@ -113,6 +109,7 @@ export class Indexer {
#suiClients: Map<SuiNet, SuiClientI>;
#electrsClients: Map<BtcNet, Electrs>;
suiIndexer: Service<SuiIndexerRpc & WorkerEntrypoint>;
compliance: Service<ComplianceRpc & WorkerEntrypoint>;

constructor(
storage: Storage,
Expand All @@ -123,6 +120,7 @@ export class Indexer {
maxRetries: number,
electrsClients: Map<BtcNet, Electrs>,
suiIndexer: Service<SuiIndexerRpc & WorkerEntrypoint>,
compliance: Service<ComplianceRpc & WorkerEntrypoint>,
) {
if (packageConfigs.length === 0) {
throw new Error("No active nBTC packages configured.");
Expand Down Expand Up @@ -154,6 +152,7 @@ export class Indexer {
this.#packageConfigs = pkgCfgMap;
this.#suiClients = suiClients;
this.suiIndexer = suiIndexer;
this.compliance = compliance;
}

async hasNbtcMintTx(txId: string): Promise<boolean> {
Expand Down Expand Up @@ -711,14 +710,26 @@ export class Indexer {
const client = this.getSuiClient(config.sui_network);
const pkgKey = config.nbtc_pkg;

const { filteredMintArgs, filteredProcessedKeys } =
await this.filterSanctionedAddresses(mintArgs, processedKeys, config.btc_network);

if (filteredMintArgs.length === 0) {
logger.warn({
msg: "All transactions in batch were filtered out (sanctioned addresses)",
setupId,
pkgKey,
});
continue;
}

logger.info({
msg: "Minting: Sending batch of mints to Sui",
count: mintArgs.length,
count: filteredMintArgs.length,
setupId,
pkgKey,
});

const result = await client.tryMintNbtcBatch(mintArgs);
const result = await client.tryMintNbtcBatch(filteredMintArgs);
if (!result) {
// Pre-submission error (network failure, validation error, etc.)
logger.error({
Expand All @@ -728,7 +739,7 @@ export class Indexer {
pkgKey,
});
await this.storage.batchUpdateNbtcMintTxs(
processedKeys.map((p) => ({
filteredProcessedKeys.map((p) => ({
txId: p.tx_id,
vout: p.vout,
status: MintTxStatus.MintFailed,
Expand All @@ -746,7 +757,7 @@ export class Indexer {
pkgKey,
});
await this.storage.batchUpdateNbtcMintTxs(
processedKeys.map((p) => ({
filteredProcessedKeys.map((p) => ({
txId: p.tx_id,
vout: p.vout,
status: MintTxStatus.Minted,
Expand All @@ -763,7 +774,7 @@ export class Indexer {
suiTxDigest,
});
await this.storage.batchUpdateNbtcMintTxs(
processedKeys.map((p) => ({
filteredProcessedKeys.map((p) => ({
txId: p.tx_id,
vout: p.vout,
status: MintTxStatus.MintFailed,
Expand All @@ -774,6 +785,56 @@ export class Indexer {
}
}

private async filterSanctionedAddresses(
mintArgs: MintBatchArg[],
processedKeys: ProcessedKey[],
btcNetwork: BtcNet,
): Promise<{ filteredMintArgs: MintBatchArg[]; filteredProcessedKeys: ProcessedKey[] }> {
// mapping between current mint index and sender address list
const senderAddressMap = new Map<number, string[]>();
for (let i = 0; i < mintArgs.length; i++) {
const args = mintArgs[i];
if (!args) continue;
const senderAddresses = await extractSenderAddresses(args.tx, btcNetwork);
senderAddressMap.set(i, senderAddresses);
}

const allAddresses = Array.from(senderAddressMap.values()).flat();
const uniqueAddresses = [...new Set(allAddresses)];
const blockedResults = await this.compliance.isBtcBlocked(uniqueAddresses);
const blockedAddressSet = new Set(
Object.entries(blockedResults)
.filter(([_, isBlocked]) => isBlocked)
.map(([addr]) => addr),
);

const filteredMintArgs: MintBatchArg[] = [];
const filteredProcessedKeys: ProcessedKey[] = [];

for (let i = 0; i < mintArgs.length; i++) {
const args = mintArgs[i];
if (!args) continue;

const senderAddresses = senderAddressMap.get(i) || [];
const hasBlockedAddress = senderAddresses.some((addr) => blockedAddressSet.has(addr));
if (hasBlockedAddress) {
const blockedAddrs = senderAddresses.filter((addr) => blockedAddressSet.has(addr));
logger.error({
msg: "Sanctioned address detected, skipping mint",
txId: args.tx.getId(),
senderAddresses: blockedAddrs,
});
continue;
}

filteredMintArgs.push(args);
const key = processedKeys[i];
if (key) filteredProcessedKeys.push(key);
}

return { filteredMintArgs, filteredProcessedKeys };
}

async detectMintedReorgs(blockHeight: number): Promise<void> {
logger.debug({
msg: "Checking for reorgs on minted transactions",
Expand Down
9 changes: 8 additions & 1 deletion packages/btcindexer/src/models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Transaction } from "bitcoinjs-lib";
import { networks, Transaction, type Network } from "bitcoinjs-lib";
import { BitcoinTxStatus, BtcNet, type BlockQueueRecord } from "@gonative-cc/lib/nbtc";
import type { NbtcPkg, SuiNet } from "@gonative-cc/lib/nsui";

Expand Down Expand Up @@ -195,3 +195,10 @@ export const enum InsertBlockStatus {
}

export type InsertBlockResult = InsertBlockStatus;

export const btcNetworkCfg: Record<BtcNet, Network> = {
[BtcNet.MAINNET]: networks.bitcoin,
[BtcNet.TESTNET]: networks.testnet,
[BtcNet.REGTEST]: networks.regtest,
[BtcNet.SIGNET]: networks.testnet,
};
3 changes: 2 additions & 1 deletion packages/btcindexer/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 0f6ea38d8b9ed234c528c82214007cdc)
// Generated by Wrangler by running `wrangler types` (hash: 61d08006c8ea6e82b55fc380188e259a)
// Runtime types generated with workerd@1.20251210.0 2025-06-20 nodejs_compat
declare namespace Cloudflare {
interface GlobalProps {
Expand All @@ -15,6 +15,7 @@ declare namespace Cloudflare {
DB: D1Database;
NBTC_MINTING_SIGNER_MNEMONIC: SecretsStoreSecret;
SuiIndexer: Fetcher /* sui-indexer */;
Compliance: Service /* entrypoint RPC from compliance */;
}
}
interface Env extends Cloudflare.Env {}
Expand Down
5 changes: 4 additions & 1 deletion packages/btcindexer/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
"services": [{ "binding": "SuiIndexer", "service": "sui-indexer" }],
"services": [
{ "binding": "SuiIndexer", "service": "sui-indexer" },
{ "binding": "Compliance", "service": "compliance", "entrypoint": "RPC" },
],
//
"d1_databases": [
{
Expand Down
5 changes: 5 additions & 0 deletions packages/compliance/db/migrations/0001_initial_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS sanctioned_addresses (
address TEXT NOT NULL,
chain INTEGER NOT NULL, -- 0 - bitcoin, 1 - sui
PRIMARY KEY (address, chain)
) STRICT;
5 changes: 4 additions & 1 deletion packages/compliance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"cf-typegen": "wrangler types",
"test": "bun test"
"test": "bun test",
"db:migrate": "wrangler d1 migrations apply compliance",
"db:migrate:local": "wrangler d1 migrations apply compliance --local",
"db:migrate:backstage": "wrangler d1 migrations apply compliance --remote"
},
"dependencies": {
"@gonative-cc/lib": "workspace:*"
Expand Down
12 changes: 9 additions & 3 deletions packages/compliance/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { D1Storage } from "./storage";
import { logger, logError } from "@gonative-cc/lib/logger";
import { updateSanctionedAddress } from "./sanction";

// Export RPC entrypoints for service bindings
export { RPC } from "./rpc";

export default {
async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> {
// TODO: run DB updates
const storage = new D1Storage(env.DB);
logger.debug({ msg: "Cron job starting" });
try {
await updateSanctionedAddress(env.DB);
logger.info({ msg: "Cron job finished successfully" });
} catch (e) {
logError({ msg: "Cron job failed", method: "scheduled" }, e);
}
},
} satisfies ExportedHandler<Env>;
15 changes: 4 additions & 11 deletions packages/compliance/src/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import { WorkerEntrypoint } from "cloudflare:workers";

import { D1Storage } from "./storage";
import type {
ConfirmingRedeemReq,
RedeemRequestEventRaw,
RedeemRequestResp,
FinalizeRedeemTx,
} from "@gonative-cc/lib/rpc-types";
import type { ComplianceRpc } from "@gonative-cc/lib/rpc-types";

/**
* RPC entrypoint for the worker.
*
* @see https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/
*/
export class RPC extends WorkerEntrypoint<Env> {
// TODO: check if we can use a proper type for address instead of string
async isBtcBlocked(btcAddrs: string[]): Promise<boolean> {
export class RPC extends WorkerEntrypoint<Env> implements ComplianceRpc {
async isBtcBlocked(btcAddresses: string[]): Promise<Record<string, boolean>> {
const storage = new D1Storage(this.env.DB);
return storage.isBtcBlocked(btcAddrs);
return storage.isBtcBlocked(btcAddresses);
}
}
Loading