Skip to content
42 changes: 35 additions & 7 deletions packages/sui-indexer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,44 @@ async function runRedeemSolver(storage: D1Storage, env: Env, activeNetworks: Sui
env.REDEEM_DURATION_MS,
);

const results = await Promise.allSettled([
service.processPendingRedeems(), // propose a solution
service
.solveReadyRedeems() // trigger status change
.then(() => service.processSolvedRedeems()), // request signatures
service.broadcastReadyRedeems(), // broadcast fully signed txs
]);
const results: PromiseSettledResult<void>[] = [];

// 1. Refill presign pool
try {
await service.refillPresignPool(activeNetworks);
results.push({ status: "fulfilled", value: undefined });
} catch (error) {
results.push({ status: "rejected", reason: error });
}

// 2. Propose solutions
try {
await service.processPendingRedeems();
results.push({ status: "fulfilled", value: undefined });
} catch (error) {
results.push({ status: "rejected", reason: error });
}

// 3. Solve and Sign
try {
await service.solveReadyRedeems();
await service.processSolvedRedeems();
results.push({ status: "fulfilled", value: undefined });
} catch (error) {
results.push({ status: "rejected", reason: error });
}

// 4. Broadcast
try {
await service.broadcastReadyRedeems();
results.push({ status: "fulfilled", value: undefined });
} catch (error) {
results.push({ status: "rejected", reason: error });
}

// Check for any rejected promises and log errors
reportErrors(results, "runRedeemSolver", "Processing redeems error", [
"refillPresignPool",
"processPendingRedeems",
"solveReadyRedeems/processSolvedRedeems",
"broadcastReadyRedeems",
Expand Down
49 changes: 48 additions & 1 deletion packages/sui-indexer/src/redeem-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type { BtcIndexerRpc } from "@gonative-cc/btcindexer/rpc-interface";
import { computeBtcSighash, DEFAULT_FEE_SATS, type UtxoInput, type TxOutput } from "./sighash";

const MAXIMUM_NUMBER_UTXO = 100;
const PRESIGN_POOL_TARGET = 80;
const MAX_CREATE_PER_PTB = 20;

export class RedeemService {
constructor(
Expand All @@ -25,6 +27,47 @@ export class RedeemService {
throw new Error("No SuiClients configured");
}
}
// Makes sure we have enough presigns in the queue
async refillPresignPool(activeNetworks: SuiNet[]) {
await Promise.allSettled(activeNetworks.map((net) => this.refillNetworkPool(net)));
}

private async refillNetworkPool(network: SuiNet) {
const count = await this.storage.getPresignCount(network);
if (count >= PRESIGN_POOL_TARGET) {
return;
}
const needed = PRESIGN_POOL_TARGET - count;
const toCreate = Math.min(needed, MAX_CREATE_PER_PTB);
logger.debug({
msg: "Filling presign pool",
network,
currentCount: count,
creating: toCreate,
});
const client = this.getSuiClient(network);
try {
const presignIds = await client.requestIkaPresigns(toCreate);
for (const presignId of presignIds) {
await this.storage.insertPresignObject(presignId, network);
}
logger.debug({
msg: "Created presign objects",
network,
count: presignIds.length,
});
} catch (e) {
logError(
{
msg: "Failed to create presign objects",
method: "refillNetworkPool",
network,
count: toCreate,
},
e,
);
}
}

// Propose a solution for pending redeems.
async processPendingRedeems() {
Expand Down Expand Up @@ -216,7 +259,11 @@ export class RedeemService {
msg: "No presign object in pool, creating new one",
redeemId: req.redeem_id,
});
presignId = await client.requestIkaPresign();
const presigns = await client.requestIkaPresigns(1);
if (presigns.length === 0 || !presigns[0]) {
throw new Error("Failed to create presign object");
}
presignId = presigns[0];
} else {
logger.debug({
msg: "Using existing presign object from pool",
Expand Down
54 changes: 36 additions & 18 deletions packages/sui-indexer/src/redeem-sui-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface SuiClient {
ikaClient(): IkaClient;
proposeRedeemUtxos(args: ProposeRedeemCall): Promise<string>;
solveRedeemRequest(args: SolveRedeemCall): Promise<string>;
requestIkaPresign(): Promise<string>;
requestIkaPresigns(count: number): Promise<string[]>;
requestInputSignature(
redeemId: number,
inputIdx: number,
Expand Down Expand Up @@ -171,45 +171,63 @@ class SuiClientImp implements SuiClient {
return result.digest;
}

async requestIkaPresign(): Promise<string> {
async requestIkaPresigns(count: number): Promise<string[]> {
if (count <= 0) return [];
const tx = new Transaction();
const signer = this.signer.toSuiAddress();
const ikaCoins = await this.#ika.fetchAllIkaCoins(signer);
const { preparedCoin: paymentIka } = prepareCoin(ikaCoins, BigInt(this.ikaPresignCost), tx);
const totalCost = BigInt(this.ikaPresignCost) * BigInt(count);
const { preparedCoin: paymentIka } = prepareCoin(ikaCoins, totalCost, tx);

if (!this.encryptionKeyId) {
const dWalletEncryptionKey = await this.#ika.getLatestNetworkEncryptionKeyId();
this.encryptionKeyId = dWalletEncryptionKey;
}

// TODO: Implement recovery for unused presign objects.
// If the worker crashes after creating a presign but before using it, the presign object
// remains in the wallet, to be used. We should scan for it or save it in a db
const presignCap = this.#ika.requestGlobalPresign(
tx,
const amounts = Array(count).fill(this.ikaPresignCost);
const coins = tx.splitCoins(
paymentIka,
tx.gas,
this.encryptionKeyId,
amounts.map((a) => BigInt(a)),
);

tx.transferObjects([presignCap], this.signer.toSuiAddress());
const caps = [];
for (let i = 0; i < count; i++) {
const presignCap = this.#ika.requestGlobalPresign(
tx,
coins[i]!,
tx.gas,
this.encryptionKeyId,
);
caps.push(presignCap);
}
tx.transferObjects([...caps, paymentIka], this.signer.toSuiAddress());

const result = await this.#sui.signAndExecuteTransaction({
signer: this.signer,
transaction: tx,
options: { showEvents: true },
});

if (result.effects?.status.status !== "success") {
throw new Error(`Presign request failed: ${result.effects?.status.error}`);
throw new Error(`Batch presign request failed: ${result.effects?.status.error}`);
}

const event = result.events?.find((e) => e.type.includes("PresignRequestEvent"));
if (!event) {
throw new Error("PresignRequestEvent not found");
const presignIds: string[] = [];
if (result.events) {
for (const event of result.events) {
if (event.type.includes("PresignRequestEvent")) {
const decoded = this.#ika.decodePresignRequestEvent(event.bcs as string);
presignIds.push(decoded.presign_id);
}
}
}

if (presignIds.length !== count) {
throw new Error(
`Expected ${count} presign IDs, but got ${presignIds.length}. Transaction digest: ${result.digest}`,
);
}

const decoded = this.#ika.decodePresignRequestEvent(event.bcs as string);
return decoded.presign_id;
return presignIds;
}

async requestInputSignature(
Expand Down
16 changes: 16 additions & 0 deletions packages/sui-indexer/src/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,22 @@ describe("IndexerStorage", () => {
expect(popped3).toBeNull();
});

it("should count presign objects", async () => {
const net1 = "testnet";
await storage.insertPresignObject("presign1", net1);
await storage.insertPresignObject("presign2", net1);
await storage.insertPresignObject("presign3", "mainnet");

const count = await storage.getPresignCount(net1);
expect(count).toBe(2);

const countMain = await storage.getPresignCount("mainnet");
expect(countMain).toBe(1);

const countEmpty = await storage.getPresignCount("devnet");
expect(countEmpty).toBe(0);
});

it("getPendingRedeems should return pending redeems ordered by created_at", async () => {
await insertRedeemRequest(storage, 2, "redeemer1", recipientScript, 5000, 2000, "0xSuiTx2");
await insertRedeemRequest(storage, 1, "redeemer1", recipientScript, 3000, 1000, "0xSuiTx1");
Expand Down
8 changes: 8 additions & 0 deletions packages/sui-indexer/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ export class D1Storage {
return results.map((r) => toSuiNet(r.sui_network));
}

async getPresignCount(network: SuiNet): Promise<number> {
const result = await this.db
.prepare("SELECT COUNT(*) as count FROM presign_objects WHERE sui_network = ?")
.bind(network)
.first<{ count: number }>();
return result?.count || 0;
}

async popPresignObject(network: SuiNet): Promise<string | null> {
const result = await this.db
.prepare(
Expand Down
Loading