Skip to content

Commit ce3069c

Browse files
Rcc999sczembor
andauthored
feat: PoC redeem frontrun protection (#335)
* feat: redeem frontrun protection Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * fix: dry-run approach based on discussion with Stan Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments based on discussion with Stan Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Prettier fix Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved comments Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved prettier fix Signed-off-by: Rayane Charif <rayane.charif@gonative.cc> * Resolved lint errros 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> Co-authored-by: sczembor <43810037+sczembor@users.noreply.github.com>
1 parent a6b1abb commit ce3069c

File tree

2 files changed

+116
-22
lines changed

2 files changed

+116
-22
lines changed

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

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,6 @@ export class RedeemService {
326326
const availableUtxos = await this.storage.getAvailableUtxos(req.setup_id);
327327
const selectedUtxos = selectUtxos(availableUtxos, req.amount);
328328

329-
// TODO: we should continue only if our solution is better than the existing one - in case
330-
// someone frontrun us.
331-
332329
if (!selectedUtxos) {
333330
logger.warn({
334331
msg: "Insufficient UTXOs for request",
@@ -340,24 +337,69 @@ export class RedeemService {
340337

341338
try {
342339
const client = this.getSuiClient(req.sui_network);
343-
const txDigest = await client.proposeRedeemUtxos({
340+
const result = await client.proposeRedeemUtxos({
344341
redeemId: req.redeem_id,
345342
utxoIds: selectedUtxos.map((u) => u.nbtc_utxo_id),
346343
nbtcPkg: req.nbtc_pkg,
347344
nbtcContract: req.nbtc_contract,
348345
});
349346

350-
logger.info({
351-
msg: "Proposed UTXOs for redeem request",
352-
redeemId: req.redeem_id,
353-
txDigest: txDigest,
354-
});
355-
await this.storage.markRedeemProposed(
356-
req.redeem_id,
357-
selectedUtxos.map((u) => u.nbtc_utxo_id),
358-
this.utxoLockTimeMs,
359-
);
347+
if (result.effects?.status.status === "success") {
348+
logger.info({
349+
msg: "Proposed UTXOs for redeem request",
350+
redeemId: req.redeem_id,
351+
txDigest: result.digest,
352+
});
353+
await this.storage.markRedeemProposed(
354+
req.redeem_id,
355+
selectedUtxos.map((u) => u.nbtc_utxo_id),
356+
this.utxoLockTimeMs,
357+
);
358+
} else {
359+
const error = result.effects?.status.error ?? "";
360+
if (isRedeemProgressed(error)) {
361+
logger.info({
362+
msg: "Redeem already progressed past proposal phase",
363+
redeemId: req.redeem_id,
364+
txDigest: result.digest,
365+
error,
366+
});
367+
let onChainUtxoIds: number[] = [];
368+
try {
369+
onChainUtxoIds = await client.getRedeemUtxoIds(
370+
req.redeem_id,
371+
req.nbtc_pkg,
372+
req.nbtc_contract,
373+
);
374+
} catch (e) {
375+
logError(
376+
{
377+
msg: "Failed to fetch on-chain UTXO IDs",
378+
method: "redeemReqProposeSolution",
379+
redeemId: req.redeem_id,
380+
},
381+
e,
382+
);
383+
}
384+
await this.storage.markRedeemProposed(
385+
req.redeem_id,
386+
onChainUtxoIds,
387+
this.utxoLockTimeMs,
388+
);
389+
} else {
390+
// TODO: Add specific error codes in the smart contract for
391+
// better classification of contract aborts.
392+
// For now, leave as pending to retry on next cron tick.
393+
logger.warn({
394+
msg: "Proposal failed on-chain, will retry",
395+
redeemId: req.redeem_id,
396+
txDigest: result.digest,
397+
error,
398+
});
399+
}
400+
}
360401
} catch (e: unknown) {
402+
// Network error: leave as pending so next cron retry.
361403
logError(
362404
{
363405
msg: "Failed to propose UTXOs for redeem request",
@@ -404,6 +446,13 @@ export class RedeemService {
404446
}
405447
}
406448

449+
// Contract error messages indicating the redeem has progressed past the proposal phase.
450+
function isRedeemProgressed(error: string): boolean {
451+
return (
452+
error.includes("not in resolving status") || error.includes("resolving window has expired")
453+
);
454+
}
455+
407456
// V1 version
408457
function selectUtxos(available: Utxo[], targetAmount: number): Utxo[] | null {
409458
let sum = 0;

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

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { SuiClient as Client, getFullnodeUrl } from "@mysten/sui/client";
1+
import {
2+
type SuiTransactionBlockResponse,
3+
SuiClient as Client,
4+
getFullnodeUrl,
5+
} from "@mysten/sui/client";
6+
import { bcs } from "@mysten/bcs";
27
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
38
import { Transaction } from "@mysten/sui/transactions";
49

@@ -20,7 +25,7 @@ export interface SuiClientCfg {
2025

2126
export interface SuiClient {
2227
ikaClient(): IkaClient;
23-
proposeRedeemUtxos(args: ProposeRedeemCall): Promise<string>;
28+
proposeRedeemUtxos(args: ProposeRedeemCall): Promise<SuiTransactionBlockResponse>;
2429
solveRedeemRequest(args: SolveRedeemCall): Promise<string>;
2530
finalizeRedeem(args: FinalizeRedeemCall): Promise<string>;
2631
requestIkaPresigns(count: number): Promise<string[]>;
@@ -39,6 +44,7 @@ export interface SuiClient {
3944
nbtcContract: string,
4045
): Promise<void>;
4146
getRedeemBtcTx(redeemId: number, nbtcPkg: string, nbtcContract: string): Promise<string>;
47+
getRedeemUtxoIds(redeemId: number, nbtcPkg: string, nbtcContract: string): Promise<number[]>;
4248
}
4349

4450
class SuiClientImp implements SuiClient {
@@ -111,7 +117,50 @@ class SuiClientImp implements SuiClient {
111117
return Buffer.from(decoded).toString("hex");
112118
}
113119

114-
async proposeRedeemUtxos(args: ProposeRedeemCall): Promise<string> {
120+
async getRedeemUtxoIds(
121+
redeemId: number,
122+
nbtcPkg: string,
123+
nbtcContract: string,
124+
): Promise<number[]> {
125+
const tx = new Transaction();
126+
const redeem = tx.add(
127+
nBTCContractModule.redeemRequest({
128+
package: nbtcPkg,
129+
arguments: {
130+
contract: nbtcContract,
131+
redeemId: redeemId,
132+
},
133+
}),
134+
);
135+
136+
tx.add(
137+
RedeemRequestModule.utxoIds({
138+
package: nbtcPkg,
139+
arguments: {
140+
r: redeem,
141+
},
142+
}),
143+
);
144+
145+
const result = await this.#sui.devInspectTransactionBlock({
146+
transactionBlock: tx,
147+
sender: this.signer.toSuiAddress(),
148+
});
149+
150+
if (result.error) {
151+
throw new Error(`DevInspect failed: ${result.error}`);
152+
}
153+
154+
const rawResult = result.results?.[1]?.returnValues?.[0]?.[0];
155+
if (!rawResult) {
156+
return [];
157+
}
158+
159+
const utxoIds = bcs.vector(bcs.u64()).parse(Uint8Array.from(rawResult));
160+
return utxoIds.map(Number);
161+
}
162+
163+
async proposeRedeemUtxos(args: ProposeRedeemCall): Promise<SuiTransactionBlockResponse> {
115164
const tx = new Transaction();
116165

117166
tx.add(
@@ -135,11 +184,7 @@ class SuiClientImp implements SuiClient {
135184
},
136185
});
137186

138-
if (result.effects?.status.status !== "success") {
139-
throw new Error(`Transaction failed: ${result.effects?.status.error}`);
140-
}
141-
142-
return result.digest;
187+
return result;
143188
}
144189

145190
async solveRedeemRequest(args: SolveRedeemCall): Promise<string> {

0 commit comments

Comments
 (0)