Skip to content

Commit 1f50aad

Browse files
feat(sui-indexer): maintain per-network presign pool for redeems (#319)
* create presign objects up front in cron job Signed-off-by: sczembor <stanislaw.czembor@gmail.com> * apply code review suggestions Signed-off-by: sczembor <stanislaw.czembor@gmail.com> * apply code review suggestions Signed-off-by: sczembor <stanislaw.czembor@gmail.com> * ai suggestion Signed-off-by: sczembor <stanislaw.czembor@gmail.com> * apply code review suggestions Signed-off-by: sczembor <stanislaw.czembor@gmail.com> * Apply suggestions from code review Co-authored-by: Robert Zaremba <robert@zaremba.ch> Signed-off-by: sczembor <43810037+sczembor@users.noreply.github.com> --------- Signed-off-by: sczembor <stanislaw.czembor@gmail.com> Signed-off-by: sczembor <43810037+sczembor@users.noreply.github.com> Co-authored-by: Robert Zaremba <robert@zaremba.ch>
1 parent a65b82f commit 1f50aad

File tree

5 files changed

+141
-26
lines changed

5 files changed

+141
-26
lines changed

packages/sui-indexer/src/index.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,22 +107,42 @@ async function runRedeemSolver(storage: D1Storage, env: Env, activeNetworks: Sui
107107
env.REDEEM_DURATION_MS,
108108
);
109109

110-
const results = await Promise.allSettled([
111-
service.processPendingRedeems(), // propose a solution
112-
service
113-
.solveReadyRedeems() // trigger status change
114-
.then(() => service.processSolvedRedeems()), // request signatures
115-
service.broadcastReadyRedeems(), // broadcast fully signed txs
116-
]);
110+
const results: PromiseSettledResult<void>[] = [];
111+
112+
results.push(await tryAsync(service.refillPresignPool(activeNetworks)));
113+
results.push(await tryAsync(service.processPendingRedeems()));
114+
115+
// Solve and Sign
116+
results.push(
117+
await tryAsync(
118+
(async () => {
119+
await service.solveReadyRedeems();
120+
await service.processSolvedRedeems();
121+
})(),
122+
),
123+
);
124+
125+
// 4. Broadcast
126+
results.push(await tryAsync(service.broadcastReadyRedeems()));
117127

118128
// Check for any rejected promises and log errors
119129
reportErrors(results, "runRedeemSolver", "Processing redeems error", [
130+
"refillPresignPool",
120131
"processPendingRedeems",
121132
"solveReadyRedeems/processSolvedRedeems",
122133
"broadcastReadyRedeems",
123134
]);
124135
}
125136

137+
async function tryAsync<T>(p: Promise<T>): Promise<PromiseSettledResult<T>> {
138+
try {
139+
const value = await p;
140+
return { status: "fulfilled", value };
141+
} catch (reason) {
142+
return { status: "rejected", reason };
143+
}
144+
}
145+
126146
/**
127147
* Helper function to report errors from `Promise.allSettled` results.
128148
*/

packages/sui-indexer/src/redeem-service.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import type { BtcIndexerRpc } from "@gonative-cc/btcindexer/rpc-interface";
1212
import { computeBtcSighash, DEFAULT_FEE_SATS, type UtxoInput, type TxOutput } from "./sighash";
1313

1414
const MAXIMUM_NUMBER_UTXO = 100;
15+
const PRESIGN_POOL_TARGET = 100;
16+
const PRESIGN_POOL_MIN_TARGET = 40;
17+
const MAX_CREATE_PER_PTB = 40;
1518

1619
export class RedeemService {
1720
constructor(
@@ -25,6 +28,52 @@ export class RedeemService {
2528
throw new Error("No SuiClients configured");
2629
}
2730
}
31+
// Makes sure we have enough presigns in the queue
32+
async refillPresignPool(nets: SuiNet[]) {
33+
await Promise.allSettled(nets.map((net) => this.refillNetworkPool(net)));
34+
}
35+
36+
private async refillNetworkPool(network: SuiNet) {
37+
let currentCount = await this.storage.getPresignCount(network);
38+
if (currentCount >= PRESIGN_POOL_MIN_TARGET) return;
39+
let needed = PRESIGN_POOL_TARGET - currentCount;
40+
41+
while (needed > 0) {
42+
const toCreate = Math.min(needed, MAX_CREATE_PER_PTB);
43+
logger.debug({
44+
msg: "Filling presign pool",
45+
network,
46+
currentCount,
47+
creating: toCreate,
48+
});
49+
50+
const client = this.getSuiClient(network);
51+
try {
52+
const presignIds = await client.requestIkaPresigns(toCreate);
53+
for (const presignId of presignIds) {
54+
await this.storage.insertPresignObject(presignId, network);
55+
}
56+
logger.debug({
57+
msg: "Created presign objects",
58+
network,
59+
count: presignIds.length,
60+
});
61+
currentCount += presignIds.length;
62+
needed -= presignIds.length;
63+
} catch (e) {
64+
logError(
65+
{
66+
msg: "Failed to create presign objects",
67+
method: "refillNetworkPool",
68+
network,
69+
count: toCreate,
70+
},
71+
e,
72+
);
73+
break;
74+
}
75+
}
76+
}
2877

2978
// Propose a solution for pending redeems.
3079
async processPendingRedeems() {
@@ -216,7 +265,11 @@ export class RedeemService {
216265
msg: "No presign object in pool, creating new one",
217266
redeemId: req.redeem_id,
218267
});
219-
presignId = await client.requestIkaPresign();
268+
const presigns = await client.requestIkaPresigns(1);
269+
if (presigns.length === 0 || !presigns[0]) {
270+
throw new Error("Failed to create presign object");
271+
}
272+
presignId = presigns[0];
220273
} else {
221274
logger.debug({
222275
msg: "Using existing presign object from pool",

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

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface SuiClient {
2222
ikaClient(): IkaClient;
2323
proposeRedeemUtxos(args: ProposeRedeemCall): Promise<string>;
2424
solveRedeemRequest(args: SolveRedeemCall): Promise<string>;
25-
requestIkaPresign(): Promise<string>;
25+
requestIkaPresigns(count: number): Promise<string[]>;
2626
requestInputSignature(
2727
redeemId: number,
2828
inputIdx: number,
@@ -171,45 +171,63 @@ class SuiClientImp implements SuiClient {
171171
return result.digest;
172172
}
173173

174-
async requestIkaPresign(): Promise<string> {
174+
async requestIkaPresigns(count: number): Promise<string[]> {
175+
if (count <= 0) return [];
175176
const tx = new Transaction();
176177
const signer = this.signer.toSuiAddress();
177178
const ikaCoins = await this.#ika.fetchAllIkaCoins(signer);
178-
const { preparedCoin: paymentIka } = prepareCoin(ikaCoins, BigInt(this.ikaPresignCost), tx);
179+
const totalCost = BigInt(this.ikaPresignCost) * BigInt(count);
180+
const { preparedCoin: paymentIka } = prepareCoin(ikaCoins, totalCost, tx);
179181

180182
if (!this.encryptionKeyId) {
181183
const dWalletEncryptionKey = await this.#ika.getLatestNetworkEncryptionKeyId();
182184
this.encryptionKeyId = dWalletEncryptionKey;
183185
}
184-
185-
// TODO: Implement recovery for unused presign objects.
186-
// If the worker crashes after creating a presign but before using it, the presign object
187-
// remains in the wallet, to be used. We should scan for it or save it in a db
188-
const presignCap = this.#ika.requestGlobalPresign(
189-
tx,
186+
const amounts = Array(count).fill(this.ikaPresignCost);
187+
const coins = tx.splitCoins(
190188
paymentIka,
191-
tx.gas,
192-
this.encryptionKeyId,
189+
amounts.map((a) => BigInt(a)),
193190
);
194191

195-
tx.transferObjects([presignCap], this.signer.toSuiAddress());
192+
const caps = [];
193+
for (let i = 0; i < count; i++) {
194+
const presignCap = this.#ika.requestGlobalPresign(
195+
tx,
196+
coins[i]!,
197+
tx.gas,
198+
this.encryptionKeyId,
199+
);
200+
caps.push(presignCap);
201+
}
202+
tx.transferObjects([...caps, paymentIka], this.signer.toSuiAddress());
203+
196204
const result = await this.#sui.signAndExecuteTransaction({
197205
signer: this.signer,
198206
transaction: tx,
199207
options: { showEvents: true },
200208
});
201209

202210
if (result.effects?.status.status !== "success") {
203-
throw new Error(`Presign request failed: ${result.effects?.status.error}`);
211+
throw new Error(`Batch presign request failed: ${result.effects?.status.error}`);
204212
}
205213

206-
const event = result.events?.find((e) => e.type.includes("PresignRequestEvent"));
207-
if (!event) {
208-
throw new Error("PresignRequestEvent not found");
214+
const presignIds: string[] = [];
215+
if (result.events) {
216+
for (const event of result.events) {
217+
if (event.type.includes("PresignRequestEvent")) {
218+
const decoded = this.#ika.decodePresignRequestEvent(event.bcs as string);
219+
presignIds.push(decoded.presign_id);
220+
}
221+
}
222+
}
223+
224+
if (presignIds.length !== count) {
225+
throw new Error(
226+
`Expected ${count} presign IDs, but got ${presignIds.length}. Transaction digest: ${result.digest}`,
227+
);
209228
}
210229

211-
const decoded = this.#ika.decodePresignRequestEvent(event.bcs as string);
212-
return decoded.presign_id;
230+
return presignIds;
213231
}
214232

215233
async requestInputSignature(

packages/sui-indexer/src/storage.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,22 @@ describe("IndexerStorage", () => {
190190
expect(popped3).toBeNull();
191191
});
192192

193+
it("should count presign objects", async () => {
194+
const net1 = "testnet";
195+
await storage.insertPresignObject("presign1", net1);
196+
await storage.insertPresignObject("presign2", net1);
197+
await storage.insertPresignObject("presign3", "mainnet");
198+
199+
const count = await storage.getPresignCount(net1);
200+
expect(count).toBe(2);
201+
202+
const countMain = await storage.getPresignCount("mainnet");
203+
expect(countMain).toBe(1);
204+
205+
const countEmpty = await storage.getPresignCount("devnet");
206+
expect(countEmpty).toBe(0);
207+
});
208+
193209
it("getPendingRedeems should return pending redeems ordered by created_at", async () => {
194210
await insertRedeemRequest(storage, 2, "redeemer1", recipientScript, 5000, 2000, "0xSuiTx2");
195211
await insertRedeemRequest(storage, 1, "redeemer1", recipientScript, 3000, 1000, "0xSuiTx1");

packages/sui-indexer/src/storage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,14 @@ export class D1Storage {
230230
return results.map((r) => toSuiNet(r.sui_network));
231231
}
232232

233+
async getPresignCount(network: SuiNet): Promise<number> {
234+
const result = await this.db
235+
.prepare("SELECT COUNT(*) as count FROM presign_objects WHERE sui_network = ?")
236+
.bind(network)
237+
.first<{ count: number }>();
238+
return Number(result?.count || 0);
239+
}
240+
233241
async popPresignObject(network: SuiNet): Promise<string | null> {
234242
const result = await this.db
235243
.prepare(

0 commit comments

Comments
 (0)