Skip to content
34 changes: 27 additions & 7 deletions packages/sui-indexer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,22 +107,42 @@ 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>[] = [];

results.push(await tryAsync(service.refillPresignPool(activeNetworks)));
results.push(await tryAsync(service.processPendingRedeems()));

// Solve and Sign
results.push(
await tryAsync(
(async () => {
await service.solveReadyRedeems();
await service.processSolvedRedeems();
})(),
),
);

// 4. Broadcast
results.push(await tryAsync(service.broadcastReadyRedeems()));

// Check for any rejected promises and log errors
reportErrors(results, "runRedeemSolver", "Processing redeems error", [
"refillPresignPool",
"processPendingRedeems",
"solveReadyRedeems/processSolvedRedeems",
"broadcastReadyRedeems",
]);
}

async function tryAsync<T>(p: Promise<T>): Promise<PromiseSettledResult<T>> {
try {
const value = await p;
return { status: "fulfilled", value };
} catch (reason) {
return { status: "rejected", reason };
}
}

/**
* Helper function to report errors from `Promise.allSettled` results.
*/
Expand Down
55 changes: 54 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,9 @@ 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 = 100;
const PRESIGN_POOL_MIN_TARGET = 40;
const MAX_CREATE_PER_PTB = 40;

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

private async refillNetworkPool(network: SuiNet) {
let currentCount = await this.storage.getPresignCount(network);
if (currentCount >= PRESIGN_POOL_MIN_TARGET) return;
let needed = PRESIGN_POOL_TARGET - currentCount;

while (needed > 0) {
const toCreate = Math.min(needed, MAX_CREATE_PER_PTB);
logger.debug({
msg: "Filling presign pool",
network,
currentCount,
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,
});
currentCount += presignIds.length;
needed -= presignIds.length;
} catch (e) {
logError(
{
msg: "Failed to create presign objects",
method: "refillNetworkPool",
network,
count: toCreate,
},
e,
);
break;
}
}
}

// Propose a solution for pending redeems.
async processPendingRedeems() {
Expand Down Expand Up @@ -216,7 +265,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 Number(result?.count || 0);
}

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