Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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: 9 additions & 0 deletions packages/btcindexer/db/migrations/0001_initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS setups (
lc_pkg TEXT NOT NULL,
lc_contract TEXT NOT NULL,
nbtc_fallback_addr TEXT NOT NULL,
ika_coordinator_pkg TEXT, -- IKA coordinator package for this setup's network
is_active INTEGER NOT NULL DEFAULT TRUE,
UNIQUE(sui_network, btc_network, nbtc_pkg)
) STRICT;
Expand Down Expand Up @@ -117,6 +118,14 @@ CREATE TABLE IF NOT EXISTS indexer_state (
FOREIGN KEY (setup_id) REFERENCES setups(id)
) STRICT;

CREATE TABLE IF NOT EXISTS ika_state(
sui_network TEXT NOT NULL,
coordinator_pkg_id TEXT NOT NULL,
ika_cursor TEXT NOT NULL, -- last processed cursor state
updated_at INTEGER, -- epoch time in ms
PRIMARY KEY (sui_network, coordinator_pkg_id)
) STRICT;

CREATE TABLE IF NOT EXISTS presign_objects (
presign_id TEXT NOT NULL PRIMARY KEY,
sui_network TEXT NOT NULL, -- for simplicity, we can reuse the presigns between the setups in the same network
Expand Down
6 changes: 5 additions & 1 deletion packages/btcindexer/src/btcindexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Service } from "@cloudflare/workers-types";
import type { WorkerEntrypoint } from "cloudflare:workers";
import type { SuiIndexerRpc } from "@gonative-cc/sui-indexer/rpc-interface";
import { logError, logger } from "@gonative-cc/lib/logger";
import { getMnemonic } from "@gonative-cc/lib/secrets";
import { OP_RETURN } from "./opcodes";
import { BitcoinMerkleTree } from "./bitcoin-merkle-tree";
import { SuiClient, type SuiClientI } from "./sui_client";
Expand Down Expand Up @@ -54,7 +55,10 @@ export async function indexerFromEnv(env: Env): Promise<Indexer> {
throw new Error("Invalid MAX_NBTC_MINT_TX_RETRIES in config. Must be a number >= 0.");
}

const mnemonic = await env.NBTC_MINTING_SIGNER_MNEMONIC.get();
const mnemonic = await getMnemonic(env.NBTC_MINTING_SIGNER_MNEMONIC);
if (!mnemonic) {
throw new Error("Failed to retrieve mnemonic");
}
const suiClients = new Map<SuiNet, SuiClient>();
for (const p of packageConfigs) {
if (!suiClients.has(p.sui_network))
Expand Down
23 changes: 23 additions & 0 deletions packages/lib/src/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { logger } from "./logger";

interface SecretStore {
get(): Promise<string>;
}

/**
* Retrieves the mnemonic from the secrets store with proper error handling.
* Returns the mnemonic string or null if not found/failed.
*/
export async function getMnemonic(secret: SecretStore): Promise<string | null> {
try {
const mnemonic = await secret.get();
if (!mnemonic) {
logger.error({ msg: "Missing NBTC_MINTING_SIGNER_MNEMONIC" });
return null;
}
return mnemonic;
} catch (error) {
logger.error({ msg: "Failed to retrieve NBTC_MINTING_SIGNER_MNEMONIC", error });
return null;
}
}
1 change: 1 addition & 0 deletions packages/lib/src/test-helpers/init_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const tables = [
"nbtc_deposit_addresses",
"btc_blocks",
"indexer_state",
"ika_state",
"presign_objects",
"setups",
];
Expand Down
6 changes: 3 additions & 3 deletions packages/sui-indexer/src/graphql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface EventsBatch {

export interface EventFetcher {
fetchEvents: (
packages: { id: string; cursor: string | null }[],
packages: { id: string; cursor: string | null; module?: string }[],
) => Promise<Record<string, EventsBatch>>;
}

Expand Down Expand Up @@ -77,15 +77,15 @@ export class SuiGraphQLClient implements EventFetcher {
}

async fetchEvents(
packages: { id: string; cursor: string | null }[],
packages: { id: string; cursor: string | null; module?: string }[],
): Promise<Record<string, EventsBatch>> {
if (packages.length === 0) return {};

const query = buildMultipleEventsQuery(packages.length);
const variables: Record<string, string | null> = {};

packages.forEach((pkg, i) => {
variables[`filter${i}`] = `${pkg.id}::nbtc`;
variables[`filter${i}`] = `${pkg.id}::${pkg.module ?? "nbtc"}`;
variables[`cursor${i}`] = pkg.cursor;
});

Expand Down
90 changes: 81 additions & 9 deletions packages/sui-indexer/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@ import {
type RedeemRequestEventRaw,
type SolvedEventRaw,
type SignatureRecordedEventRaw,
type CompletedSignEventRaw,
type RejectedSignEventRaw,
type SuiEventNode,
UtxoStatus,
} from "./models";
import { logger } from "@gonative-cc/lib/logger";
import { fromBase64 } from "@mysten/sui/utils";
import type { SuiClient } from "./redeem-sui-client";

export class SuiEventHandler {
private storage: D1Storage;
private setupId: number;

constructor(storage: D1Storage, setupId: number) {
this.storage = storage;
this.setupId = setupId;
}
export class NbtcEventHandler {
constructor(
private storage: D1Storage,
private setupId: number,
) {}

public async handleEvents(events: SuiEventNode[]) {
for (const e of events) {
Expand All @@ -38,7 +38,7 @@ export class SuiEventHandler {
}
}

private async handleMint(txDigest: string, e: MintEventRaw) {
private async handleMint(_txDigest: string, e: MintEventRaw) {
// NOTE: Sui contract gives us the txid in big-endian, but bitcoinjs-lib's tx.getId()
// returns it in little-endian (see https://github.com/bitcoinjs/bitcoinjs-lib/blob/dc8d9e26f2b9c7380aec7877155bde97594a9ade/ts_src/transaction.ts#L617)
// so we reverse here to match what the btcindexer uses
Expand Down Expand Up @@ -106,3 +106,75 @@ export class SuiEventHandler {
});
}
}
export class IkaEventHandler {
constructor(
private storage: D1Storage,
private suiClient: SuiClient,
) {}

public async handleEvents(events: SuiEventNode[]) {
for (const e of events) {
if (e.type.includes("::coordinator_inner::CompletedSignEvent")) {
await this.handleCompletedSign(e);
} else if (e.type.includes("::coordinator_inner::RejectedSignEvent")) {
await this.handleRejectedSign(e);
}
}
}

private async handleCompletedSign(e: SuiEventNode) {
const data = e.json as CompletedSignEventRaw;
const signId = data.sign_id as string;
logger.info({
msg: "Ika signature completed",
sign_id: signId,
is_future_sign: data.is_future_sign, // true if it's Ika future transaction signature type
signature_length: data.signature.length,
txDigest: e.txDigest,
});

// IKA coordinator is shared across protocols, so we only process sign IDs that match our redeems.
// The final signature is recorded via SignatureRecordedEvent from nbtc.move (handled above).
const redeemInfo = await this.storage.getRedeemInfoBySignId(signId);
if (!redeemInfo) {
logger.debug({ msg: "Sign ID not found in our redeems, ignoring", sign_id: signId });
return;
}

await this.suiClient.validateSignatures(
redeemInfo.redeem_id,
[{ input_index: redeemInfo.input_index, sign_id: signId }],
redeemInfo.nbtc_pkg,
redeemInfo.nbtc_contract,
);
await this.storage.markRedeemInputVerified(redeemInfo.redeem_id, redeemInfo.utxo_id);

logger.info({
msg: "Recorded Ika signature",
redeem_id: redeemInfo.redeem_id,
utxo_id: redeemInfo.utxo_id,
sign_id: signId,
});
}

private async handleRejectedSign(e: SuiEventNode) {
const data = e.json as RejectedSignEventRaw;
const signId = data.sign_id as string;
const redeemInfo = await this.storage.getRedeemInfoBySignId(signId);
if (!redeemInfo) {
logger.warn({
msg: "Rejected sign ID not found in our redeems, ignoring",
sign_id: signId,
});
return;
}

logger.debug({
msg: "Ika signature rejected, clearing sign_id for retry",
sign_id: signId,
redeem_id: redeemInfo.redeem_id,
utxo_id: redeemInfo.utxo_id,
});
await this.storage.clearRedeemInputSignId(redeemInfo.redeem_id, redeemInfo.utxo_id);
}
}
80 changes: 51 additions & 29 deletions packages/sui-indexer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type { NetworkConfig } from "./models";
import { Processor } from "./processor";
import { D1Storage } from "./storage";
import { logError, logger } from "@gonative-cc/lib/logger";
import { getMnemonic } from "@gonative-cc/lib/secrets";
import { RedeemService } from "./redeem-service";
import { createSuiClients } from "./redeem-sui-client";
import { createSuiClients, type SuiClient } from "./redeem-sui-client";
import type { Service } from "@cloudflare/workers-types";
import type { WorkerEntrypoint } from "cloudflare:workers";
import type { BtcIndexerRpc } from "@gonative-cc/btcindexer/rpc-interface";
Expand All @@ -26,18 +27,26 @@ export default {
const storage = new D1Storage(env.DB);
const activeNetworks = await storage.getActiveNetworks();

const mnemonic = await getMnemonic(env.NBTC_MINTING_SIGNER_MNEMONIC);
if (!mnemonic) return;
const suiClients = await createSuiClients(activeNetworks, mnemonic);

// Run both indexer and redeem solver tasks in parallel
const results = await Promise.allSettled([
runSuiIndexer(storage, env, activeNetworks),
runRedeemSolver(storage, env, activeNetworks),
runSuiIndexer(storage, activeNetworks, suiClients),
runRedeemSolver(storage, env, suiClients, activeNetworks),
]);

// Check for any rejected promises and log errors
reportErrors(results, "scheduled", "Scheduled task error", ["SuiIndexer", "RedeemSolver"]);
},
} satisfies ExportedHandler<Env>;

async function runSuiIndexer(storage: D1Storage, env: Env, activeNetworks: SuiNet[]) {
async function runSuiIndexer(
storage: D1Storage,
activeNetworks: SuiNet[],
suiClients: Map<SuiNet, SuiClient>,
) {
if (activeNetworks.length === 0) {
logger.info({ msg: "No active packages/networks found in database." });
return;
Expand All @@ -61,7 +70,9 @@ async function runSuiIndexer(storage: D1Storage, env: Env, activeNetworks: SuiNe
networks: networksToProcess.map((n) => n.name),
});

const networkJobs = networksToProcess.map((netCfg) => poolAndProcessEvents(netCfg, storage));
const networkJobs = networksToProcess.map((netCfg) =>
poolAndProcessEvents(netCfg, storage, suiClients),
);
const results = await Promise.allSettled(networkJobs);
reportErrors(
results,
Expand All @@ -72,36 +83,47 @@ async function runSuiIndexer(storage: D1Storage, env: Env, activeNetworks: SuiNe
);
}

async function poolAndProcessEvents(netCfg: NetworkConfig, storage: D1Storage) {
async function poolAndProcessEvents(
netCfg: NetworkConfig,
storage: D1Storage,
suiClients: Map<SuiNet, SuiClient>,
) {
const client = new SuiGraphQLClient(netCfg.url);
const packages = await storage.getActiveNbtcPkgs(netCfg.name);
if (packages.length === 0) return;
logger.info({
msg: `Processing network`,
network: netCfg.name,
packageCount: packages.length,
});
const p = new Processor(netCfg, storage, client);
await p.pollAllNbtcEvents(packages);
const suiClient = suiClients.get(netCfg.name);
const p = new Processor(netCfg, storage, client, suiClient);

const nbtcPkgs = await storage.getActiveNbtcPkgs(netCfg.name);
if (nbtcPkgs.length > 0) {
logger.info({
msg: `Processing nBTC events`,
network: netCfg.name,
packageCount: nbtcPkgs.length,
});
await p.pollAllNbtcEvents(nbtcPkgs);
}

const ikaCursors = await storage.getIkaCoordinatorPkgsWithCursors(netCfg.name);
const ikaPkgIds = Object.keys(ikaCursors);
if (ikaPkgIds.length > 0) {
logger.info({
msg: `Processing IKA coordinator events`,
network: netCfg.name,
packageCount: ikaPkgIds.length,
});
await p.pollIkaEvents(ikaCursors);
}
}

async function runRedeemSolver(storage: D1Storage, env: Env, activeNetworks: SuiNet[]) {
async function runRedeemSolver(
storage: D1Storage,
env: Env,
suiClients: Map<SuiNet, SuiClient>,
activeNetworks: SuiNet[],
) {
logger.info({ msg: "Running scheduled redeem solver task..." });
let mnemonic: string;
try {
mnemonic = (await env.NBTC_MINTING_SIGNER_MNEMONIC.get()) || "";
} catch (error) {
logger.error({ msg: "Failed to retrieve NBTC_MINTING_SIGNER_MNEMONIC", error });
return;
}
if (!mnemonic) {
logger.error({ msg: "Missing NBTC_MINTING_SIGNER_MNEMONIC" });
return;
}
const clients = await createSuiClients(activeNetworks, mnemonic);
const service = new RedeemService(
storage,
clients,
suiClients,
env.BtcIndexer as unknown as Service<BtcIndexerRpc & WorkerEntrypoint>,
env.UTXO_LOCK_TIME,
env.REDEEM_DURATION_MS,
Expand Down
20 changes: 20 additions & 0 deletions packages/sui-indexer/src/models.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { SuiNet } from "@gonative-cc/lib/nsui";
import { BitcoinTxStatus } from "@gonative-cc/lib/nbtc";
import { createInterface } from "node:readline/promises";
import { CoordinatorInnerModule } from "@ika.xyz/sdk";

export enum UtxoStatus {
Available = "available",
Expand Down Expand Up @@ -147,3 +149,21 @@ export interface SolveRedeemCall {
nbtcPkg: string;
nbtcContract: string;
}

export interface IkaCursorUpdate {
coordinatorPkgId: string;
suiNetwork: SuiNet;
cursor: string;
}

export type CompletedSignEventRaw = typeof CoordinatorInnerModule.CompletedSignEvent.$inferInput;
export type RejectedSignEventRaw = typeof CoordinatorInnerModule.RejectedSignEvent.$inferInput;

export interface RedeemSignInfo {
redeem_id: number;
utxo_id: number;
input_index: number;
nbtc_pkg: string;
nbtc_contract: string;
sui_network: SuiNet;
}
Loading
Loading