Skip to content

Commit 5e319dd

Browse files
authored
feat: add IKA coordinator event listening to SUI indexer. (#322)
* feat: add IKA coordinator event listening to suis indexer. Events are only logged for now, processing will be added in a follow up PR Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved AI comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved failing test Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * feat: remove optimistic recordIkaSig polling (#324) * Removed optimistic polling from recordIkaSig into event handler so that when we listen to IKA events, we process a redeem request based on the 2 events we listen Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> --------- Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved typecheck error Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved AI comments and updated based on today's discussion during planning Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> --------- Signed-off-by: Rayane Charif <rayane.charif@gonative.cc>
1 parent 52590e4 commit 5e319dd

File tree

12 files changed

+378
-113
lines changed

12 files changed

+378
-113
lines changed

bun.lock

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/btcindexer/db/migrations/0001_initial_schema.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ CREATE TABLE IF NOT EXISTS setups (
4545
lc_pkg TEXT NOT NULL,
4646
lc_contract TEXT NOT NULL,
4747
nbtc_fallback_addr TEXT NOT NULL,
48+
ika_pkg TEXT, -- Ika coordinator pkg
4849
is_active INTEGER NOT NULL DEFAULT TRUE,
4950
UNIQUE(sui_network, btc_network, nbtc_pkg)
5051
) STRICT;
@@ -113,6 +114,7 @@ CREATE INDEX IF NOT EXISTS nbtc_redeem_solutions_redeem_id ON nbtc_redeem_soluti
113114
CREATE TABLE IF NOT EXISTS indexer_state (
114115
setup_id INTEGER PRIMARY KEY,
115116
nbtc_cursor TEXT NOT NULL, -- last processed cursor state
117+
ika_cursor TEXT, -- IKA coordinator cursor
116118
updated_at INTEGER, -- epoch time in ms
117119
FOREIGN KEY (setup_id) REFERENCES setups(id)
118120
) STRICT;

packages/btcindexer/src/btcindexer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type FinalizeRedeemTx,
1717
} from "@gonative-cc/sui-indexer/rpc-interface";
1818
import { logError, logger } from "@gonative-cc/lib/logger";
19+
import { getSecret } from "@gonative-cc/lib/secrets";
1920
import { isValidSuiAddress } from "@mysten/sui/utils";
2021
import { OP_RETURN } from "./opcodes";
2122
import { BitcoinMerkleTree } from "./bitcoin-merkle-tree";
@@ -73,7 +74,7 @@ export async function indexerFromEnv(env: Env): Promise<Indexer> {
7374
throw new Error("Invalid MAX_NBTC_MINT_TX_RETRIES in config. Must be a number >= 0.");
7475
}
7576

76-
const mnemonic = await env.NBTC_MINTING_SIGNER_MNEMONIC.get();
77+
const mnemonic = await getSecret(env.NBTC_MINTING_SIGNER_MNEMONIC);
7778
const suiClients = new Map<SuiNet, SuiClient>();
7879
for (const p of packageConfigs) {
7980
if (!suiClients.has(p.sui_network))

packages/lib/src/secrets.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
interface SecretStore {
2+
get(): Promise<string>;
3+
}
4+
5+
/**
6+
* Retrieves a secret from the secrets store.
7+
* Throws if the secret is not found or retrieval fails.
8+
*/
9+
export async function getSecret(secret: SecretStore): Promise<string> {
10+
const value = await secret.get();
11+
if (!value) {
12+
throw new Error("Secret not found in store");
13+
}
14+
return value;
15+
}

packages/sui-indexer/src/graphql-client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export interface EventsBatch {
1313

1414
export interface EventFetcher {
1515
fetchEvents: (
16-
packages: { id: string; cursor: string | null }[],
16+
packages: { id: string; cursor: string | null; module?: string }[],
1717
) => Promise<Record<string, EventsBatch>>;
1818
}
1919

@@ -77,15 +77,15 @@ export class SuiGraphQLClient implements EventFetcher {
7777
}
7878

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

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

8787
packages.forEach((pkg, i) => {
88-
variables[`filter${i}`] = `${pkg.id}::nbtc`;
88+
variables[`filter${i}`] = `${pkg.id}::${pkg.module ?? "nbtc"}`;
8989
variables[`cursor${i}`] = pkg.cursor;
9090
});
9191

packages/sui-indexer/src/handler.ts

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@ import {
44
type ProposeUtxoEventRaw,
55
type SolvedEventRaw,
66
type SignatureRecordedEventRaw,
7+
type CompletedSignEventRaw,
8+
type RejectedSignEventRaw,
79
type SuiEventNode,
810
UtxoStatus,
911
} from "./models";
1012
import { type RedeemRequestEventRaw } from "@gonative-cc/lib/rpc-types";
1113
import { logger } from "@gonative-cc/lib/logger";
1214
import { fromBase64 } from "@mysten/sui/utils";
15+
import type { SuiClient } from "./redeem-sui-client";
1316

14-
export class SuiEventHandler {
15-
private storage: D1Storage;
16-
private setupId: number;
17-
18-
constructor(storage: D1Storage, setupId: number) {
19-
this.storage = storage;
20-
this.setupId = setupId;
21-
}
17+
export class NbtcEventHandler {
18+
constructor(
19+
private storage: D1Storage,
20+
private setupId: number,
21+
) {}
2222

2323
public async handleEvents(events: SuiEventNode[]) {
2424
for (const e of events) {
@@ -38,7 +38,7 @@ export class SuiEventHandler {
3838
}
3939
}
4040

41-
private async handleMint(txDigest: string, e: MintEventRaw) {
41+
private async handleMint(_txDigest: string, e: MintEventRaw) {
4242
// NOTE: Sui contract gives us the txid in big-endian, but bitcoinjs-lib's tx.getId()
4343
// returns it in little-endian (see https://github.com/bitcoinjs/bitcoinjs-lib/blob/dc8d9e26f2b9c7380aec7877155bde97594a9ade/ts_src/transaction.ts#L617)
4444
// so we reverse here to match what the btcindexer uses
@@ -106,3 +106,94 @@ export class SuiEventHandler {
106106
});
107107
}
108108
}
109+
export class IkaEventHandler {
110+
constructor(
111+
private storage: D1Storage,
112+
private suiClient: SuiClient,
113+
) {}
114+
115+
public async handleEvents(events: SuiEventNode[]) {
116+
for (const e of events) {
117+
if (e.type.includes("::coordinator_inner::CompletedSignEvent")) {
118+
await this.handleCompletedSign(e);
119+
} else if (e.type.includes("::coordinator_inner::RejectedSignEvent")) {
120+
await this.handleRejectedSign(e);
121+
}
122+
}
123+
}
124+
125+
private async handleCompletedSign(e: SuiEventNode) {
126+
const data = e.json as CompletedSignEventRaw;
127+
if (typeof data.sign_id !== "string") {
128+
logger.error({
129+
msg: "Unexpected sign_id type in CompletedSignEvent",
130+
type: typeof data.sign_id,
131+
});
132+
return;
133+
}
134+
const signId = data.sign_id;
135+
136+
// IKA coordinator is shared across protocols, so we only process sign IDs that match our redeems.
137+
// The final signature is recorded via SignatureRecordedEvent from nbtc.move (handled in NbtcEventHandler).
138+
const redeemInfo = await this.storage.getRedeemInfoBySignId(signId);
139+
if (!redeemInfo) {
140+
return;
141+
}
142+
143+
logger.debug({
144+
msg: "Ika signature completed",
145+
sign_id: signId,
146+
is_future_sign: data.is_future_sign,
147+
txDigest: e.txDigest,
148+
});
149+
150+
try {
151+
await this.suiClient.validateSignatures(
152+
redeemInfo.redeem_id,
153+
[{ input_index: redeemInfo.input_index, sign_id: signId }],
154+
redeemInfo.nbtc_pkg,
155+
redeemInfo.nbtc_contract,
156+
);
157+
await this.storage.markRedeemInputVerified(redeemInfo.redeem_id, redeemInfo.utxo_id);
158+
159+
logger.info({
160+
msg: "Recorded Ika signature",
161+
redeem_id: redeemInfo.redeem_id,
162+
utxo_id: redeemInfo.utxo_id,
163+
sign_id: signId,
164+
});
165+
} catch (err) {
166+
logger.warn({
167+
msg: "Failed to validate Ika signature, will be handled by SignatureRecordedEvent",
168+
sign_id: signId,
169+
redeem_id: redeemInfo.redeem_id,
170+
utxo_id: redeemInfo.utxo_id,
171+
error: err instanceof Error ? err.message : String(err),
172+
});
173+
}
174+
}
175+
176+
private async handleRejectedSign(e: SuiEventNode) {
177+
const data = e.json as RejectedSignEventRaw;
178+
if (typeof data.sign_id !== "string") {
179+
logger.error({
180+
msg: "Unexpected sign_id type in RejectedSignEvent",
181+
type: typeof data.sign_id,
182+
});
183+
return;
184+
}
185+
const signId = data.sign_id;
186+
const redeemInfo = await this.storage.getRedeemInfoBySignId(signId);
187+
if (!redeemInfo) {
188+
return;
189+
}
190+
191+
logger.warn({
192+
msg: "Ika signature rejected, clearing sign_id for retry",
193+
sign_id: signId,
194+
redeem_id: redeemInfo.redeem_id,
195+
utxo_id: redeemInfo.utxo_id,
196+
});
197+
await this.storage.clearRedeemInputSignId(redeemInfo.redeem_id, redeemInfo.utxo_id);
198+
}
199+
}

packages/sui-indexer/src/index.ts

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import type { NetworkConfig } from "./models";
44
import { Processor } from "./processor";
55
import { D1Storage } from "./storage";
66
import { logError, logger } from "@gonative-cc/lib/logger";
7+
import { getSecret } from "@gonative-cc/lib/secrets";
78
import { RedeemService } from "./redeem-service";
8-
import { createSuiClients } from "./redeem-sui-client";
9+
import { createSuiClients, type SuiClient } from "./redeem-sui-client";
910
import type { Service } from "@cloudflare/workers-types";
1011
import type { WorkerEntrypoint } from "cloudflare:workers";
1112
import type { BtcIndexerRpc } from "@gonative-cc/btcindexer/rpc-interface";
@@ -26,18 +27,25 @@ export default {
2627
const storage = new D1Storage(env.DB);
2728
const activeNetworks = await storage.getActiveNetworks();
2829

30+
const mnemonic = await getSecret(env.NBTC_MINTING_SIGNER_MNEMONIC);
31+
const suiClients = await createSuiClients(activeNetworks, mnemonic);
32+
2933
// Run both indexer and redeem solver tasks in parallel
3034
const results = await Promise.allSettled([
31-
runSuiIndexer(storage, env, activeNetworks),
32-
runRedeemSolver(storage, env, activeNetworks),
35+
runSuiIndexer(storage, activeNetworks, suiClients),
36+
runRedeemSolver(storage, env, suiClients, activeNetworks),
3337
]);
3438

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

40-
async function runSuiIndexer(storage: D1Storage, env: Env, activeNetworks: SuiNet[]) {
44+
async function runSuiIndexer(
45+
storage: D1Storage,
46+
activeNetworks: SuiNet[],
47+
suiClients: Map<SuiNet, SuiClient>,
48+
) {
4149
if (activeNetworks.length === 0) {
4250
logger.info({ msg: "No active packages/networks found in database." });
4351
return;
@@ -61,7 +69,9 @@ async function runSuiIndexer(storage: D1Storage, env: Env, activeNetworks: SuiNe
6169
networks: networksToProcess.map((n) => n.name),
6270
});
6371

64-
const networkJobs = networksToProcess.map((netCfg) => poolAndProcessEvents(netCfg, storage));
72+
const networkJobs = networksToProcess.map((netCfg) =>
73+
poolAndProcessEvents(netCfg, storage, suiClients),
74+
);
6575
const results = await Promise.allSettled(networkJobs);
6676
reportErrors(
6777
results,
@@ -72,36 +82,51 @@ async function runSuiIndexer(storage: D1Storage, env: Env, activeNetworks: SuiNe
7282
);
7383
}
7484

75-
async function poolAndProcessEvents(netCfg: NetworkConfig, storage: D1Storage) {
85+
async function poolAndProcessEvents(
86+
netCfg: NetworkConfig,
87+
storage: D1Storage,
88+
suiClients: Map<SuiNet, SuiClient>,
89+
) {
90+
const suiClient = suiClients.get(netCfg.name);
91+
if (!suiClient) {
92+
logger.warn({ msg: "No SuiClient for network, skipping", network: netCfg.name });
93+
return;
94+
}
7695
const client = new SuiGraphQLClient(netCfg.url);
77-
const packages = await storage.getActiveNbtcPkgs(netCfg.name);
78-
if (packages.length === 0) return;
79-
logger.info({
80-
msg: `Processing network`,
81-
network: netCfg.name,
82-
packageCount: packages.length,
83-
});
84-
const p = new Processor(netCfg, storage, client);
85-
await p.pollAllNbtcEvents(packages);
96+
const p = new Processor(netCfg, storage, client, suiClient);
97+
98+
const nbtcPkgs = await storage.getActiveNbtcPkgs(netCfg.name);
99+
if (nbtcPkgs.length > 0) {
100+
logger.info({
101+
msg: `Processing nBTC events`,
102+
network: netCfg.name,
103+
packageCount: nbtcPkgs.length,
104+
});
105+
await p.pollAllNbtcEvents(nbtcPkgs);
106+
}
107+
108+
const ikaCursors = await storage.getIkaCoordinatorPkgsWithCursors(netCfg.name);
109+
const ikaPkgIds = Object.keys(ikaCursors);
110+
if (ikaPkgIds.length > 0) {
111+
logger.info({
112+
msg: `Processing IKA coordinator events`,
113+
network: netCfg.name,
114+
packageCount: ikaPkgIds.length,
115+
});
116+
await p.pollIkaEvents(ikaCursors);
117+
}
86118
}
87119

88-
async function runRedeemSolver(storage: D1Storage, env: Env, activeNetworks: SuiNet[]) {
120+
async function runRedeemSolver(
121+
storage: D1Storage,
122+
env: Env,
123+
suiClients: Map<SuiNet, SuiClient>,
124+
activeNetworks: SuiNet[],
125+
) {
89126
logger.info({ msg: "Running scheduled redeem solver task..." });
90-
let mnemonic: string;
91-
try {
92-
mnemonic = (await env.NBTC_MINTING_SIGNER_MNEMONIC.get()) || "";
93-
} catch (error) {
94-
logger.error({ msg: "Failed to retrieve NBTC_MINTING_SIGNER_MNEMONIC", error });
95-
return;
96-
}
97-
if (!mnemonic) {
98-
logger.error({ msg: "Missing NBTC_MINTING_SIGNER_MNEMONIC" });
99-
return;
100-
}
101-
const clients = await createSuiClients(activeNetworks, mnemonic);
102127
const service = new RedeemService(
103128
storage,
104-
clients,
129+
suiClients,
105130
env.BtcIndexer as unknown as Service<BtcIndexerRpc & WorkerEntrypoint>,
106131
env.UTXO_LOCK_TIME,
107132
env.REDEEM_DURATION_MS,

0 commit comments

Comments
 (0)