Skip to content

feat: PoC redeem frontrun protection#335

Merged
sczembor merged 20 commits intomasterfrom
rayane/redeem-frontrun-protection
Feb 23, 2026
Merged

feat: PoC redeem frontrun protection#335
sczembor merged 20 commits intomasterfrom
rayane/redeem-frontrun-protection

Conversation

@Rcc999
Copy link
Contributor

@Rcc999 Rcc999 commented Feb 4, 2026

Closes: #260

Summary by Sourcery

Implement on-chain-aware redeem UTXO proposal handling with basic frontrun protection and update Sui client response handling.

New Features:

  • Add conditional handling of redeem UTXO proposals based on on-chain contract response to support frontrun-aware behavior.

Enhancements:

  • Change Sui client redeem proposal API to return structured success status and digest instead of throwing on failure, and adjust redeem service logic accordingly.
  • Improve redeem service logging and state transitions for successful, rejected, and retried UTXO proposal attempts.
  • Tidy AGENTS documentation formatting and table alignment for workspace packages.

Documentation:

  • Reformat AGENTS.md package table and spacing for clearer presentation of workspace components.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 4, 2026

Reviewer's Guide

Implements frontrun protection for redeem UTXO proposals by changing the Sui client to return structured success metadata, updating the redeem flow to react differently to successful vs rejected on-chain proposals, and doing minor documentation/table formatting cleanup.

Sequence diagram for redeem UTXO frontrun protection flow

sequenceDiagram
    participant RedeemService
    participant Storage
    participant SuiClient
    participant SuiContract
    participant Logger

    RedeemService->>Storage: getAvailableUtxos(setupId)
    Storage-->>RedeemService: availableUtxos
    RedeemService->>RedeemService: selectUtxos(availableUtxos, amount)
    alt no_selected_utxos
        RedeemService->>Logger: warn Insufficient UTXOs
        RedeemService-->>RedeemService: return
    else selected_utxos_found
        RedeemService->>SuiClient: getSuiClient(suiNetwork)
        RedeemService->>SuiClient: proposeRedeemUtxos(proposalArgs)
        SuiClient->>SuiContract: submit transaction
        SuiContract-->>SuiClient: transaction result
        SuiClient-->>RedeemService: { success, digest }

        alt on_chain_success
            RedeemService->>Logger: info Proposed UTXOs for redeem request
            RedeemService->>Storage: markRedeemProposed(redeemId, utxoIds, utxoLockTimeMs)
        else contract_abort_better_solution
            RedeemService->>Logger: info Proposal rejected, better solution exists
            RedeemService->>Storage: markRedeemProposed(redeemId, [], 0)
        end
    end

    opt network_or_unexpected_error
        RedeemService->>Logger: error Failed to propose UTXOs
        RedeemService-->>RedeemService: leave request pending for retry
    end
Loading

Class diagram for updated SuiClient and RedeemService proposal logic

classDiagram
    class ProposeRedeemCall {
    }

    class SuiClient {
        +ikaClient() IkaClient
        +proposeRedeemUtxos(args ProposeRedeemCall) Promise~ProposeRedeemResult~
        +solveRedeemRequest(args SolveRedeemCall) Promise~string~
        +finalizeRedeem(args FinalizeRedeemCall) Promise~string~
        +requestIkaPresigns(count number) Promise~string[]~
    }

    class ProposeRedeemResult {
        +success boolean
        +digest string|null
    }

    class SuiClientImp {
        +SuiClientImp(cfg SuiClientCfg)
        +ikaClient() IkaClient
        +proposeRedeemUtxos(args ProposeRedeemCall) Promise~ProposeRedeemResult~
        +solveRedeemRequest(args SolveRedeemCall) Promise~string~
        +finalizeRedeem(args FinalizeRedeemCall) Promise~string~
        +requestIkaPresigns(count number) Promise~string[]~
        -decodePresignId(encoded string) string
    }

    class RedeemService {
        -utxoLockTimeMs number
        +getSuiClient(suiNetwork string) SuiClient
        +processRedeemRequest(req RedeemRequest) Promise~void~
    }

    class RedeemRequest {
        +redeem_id string
        +setup_id string
        +amount number
        +sui_network string
        +nbtc_pkg string
        +nbtc_contract string
    }

    class Storage {
        +getAvailableUtxos(setupId string) Promise~Utxo[]~
        +markRedeemProposed(redeemId string, utxoIds string[], lockTimeMs number) Promise~void~
    }

    class Utxo {
        +nbtc_utxo_id string
    }

    SuiClientImp ..|> SuiClient
    SuiClientImp --> ProposeRedeemResult
    SuiClient --> ProposeRedeemCall

    RedeemService --> SuiClient
    RedeemService --> Storage
    RedeemService --> RedeemRequest
    Storage --> Utxo
Loading

File-Level Changes

Change Details Files
Change Sui client redeem proposal API to return structured success metadata instead of throwing on failure.
  • Update SuiClient interface so proposeRedeemUtxos returns an object with success flag and optional digest.
  • Modify SuiClientImp.proposeRedeemUtxos to stop throwing on non-successful transactions.
  • Have SuiClientImp.proposeRedeemUtxos build and return { success, digest } based on transaction effects and digest.
packages/sui-indexer/src/redeem-sui-client.ts
Update redeem flow to handle frontrun protection and distinguish on-chain rejection from network errors.
  • Remove TODO comment about checking solution quality and instead implement logic based on contract result.
  • Prepare proposal arguments once and pass to Sui client.
  • On successful proposal, log success and mark redeem as proposed while locking selected UTXOs as before.
  • On on-chain rejection (e.g., better solution already proposed), log info and mark redeem as proposed without locking any UTXOs so processing can continue.
  • Clarify catch branch as handling network errors and keep redeem request pending for cron retry.
packages/sui-indexer/src/redeem-service.ts
Minor documentation formatting cleanup in agents overview.
  • Insert a blank line in section describing workspace content.
  • Reformat the package table for improved alignment and readability without semantic changes.
AGENTS.md

Assessment against linked issues

Issue Objective Addressed Explanation
#260 Ensure that in redeemReqProposeSolution a new redeem UTXO proposal only continues/gets accepted if it is better than an existing on-chain solution, to handle frontrunning.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sczembor sczembor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should reconsider how to check if the solution we are about to propose is better than the existing one

Copy link
Contributor

@sczembor sczembor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets remove the dry-run and basically change the logic in that function to handle the netowrk fails and on-chain aborts and update the redeem status accrodingly. Either re-attempt or just assume its proposed because someone front run us

@Rcc999 Rcc999 marked this pull request as ready for review February 17, 2026 14:52
@Rcc999 Rcc999 requested a review from a team as a code owner February 17, 2026 14:52
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Changing SuiClient.proposeRedeemUtxos to return { success, digest } instead of throwing on failure alters its error semantics; double-check all other call sites are updated to handle the new contract (including non-success statuses that are not frontrun-related).
  • In proposeRedeemUtxos, you currently collapse all non-success transaction statuses into success: false; consider returning or logging the on-chain error/status message so callers can distinguish frontrun-related aborts from other contract or gas failures.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Changing `SuiClient.proposeRedeemUtxos` to return `{ success, digest }` instead of throwing on failure alters its error semantics; double-check all other call sites are updated to handle the new contract (including non-success statuses that are not frontrun-related).
- In `proposeRedeemUtxos`, you currently collapse all non-success transaction statuses into `success: false`; consider returning or logging the on-chain error/status message so callers can distinguish frontrun-related aborts from other contract or gas failures.

## Individual Comments

### Comment 1
<location> `packages/sui-indexer/src/redeem-service.ts:349-358` </location>
<code_context>
-				selectedUtxos.map((u) => u.nbtc_utxo_id),
-				this.utxoLockTimeMs,
-			);
+			if (result.success) {
+				logger.info({
+					msg: "Proposed UTXOs for redeem request",
+					redeemId: req.redeem_id,
+					txDigest: result.digest,
+				});
+				await this.storage.markRedeemProposed(
+					req.redeem_id,
+					selectedUtxos.map((u) => u.nbtc_utxo_id),
+					this.utxoLockTimeMs,
+				);
+			} else {
+				// Contract abort: someone already proposed a better solution.
+				// Mark as proposed without locking UTXOs so the redeem continue.
</code_context>

<issue_to_address>
**suggestion:** Non-success result is treated as "better proposal" but may also include other on-chain failure modes.

The `else` path currently treats every non-success as “better solution found” and advances the redeem without locking UTXOs. Other abort causes (invalid params, invariants, misconfig, etc.) would be misclassified and also advance the flow. Consider distinguishing specific abort reasons (e.g., via error codes/strings on `effects.status.error`) so you only skip locking for the "better solution" case and handle unexpected contract errors separately.

Suggested implementation:

```typescript
			if (result.success) {
				logger.info({
					msg: "Proposed UTXOs for redeem request",
					redeemId: req.redeem_id,
					txDigest: result.digest,
				});
				await this.storage.markRedeemProposed(
					req.redeem_id,
					selectedUtxos.map((u) => u.nbtc_utxo_id),
					this.utxoLockTimeMs,
				);
			} else {
				// Distinguish "better proposal" abort from other on-chain failures.
				// We only skip locking UTXOs when the contract explicitly reports
				// that a better proposal already exists.
				const errorMessage =
					(result as any)?.effects?.status?.error ??
					(result as any)?.error ??
					"unknown on-chain error";

				const isBetterProposalError =
					typeof errorMessage === "string" &&
					errorMessage.toLowerCase().includes("better proposal");

				if (isBetterProposalError) {
					// Contract abort: someone already proposed a better solution.
					// Mark as proposed without locking UTXOs so the redeem can continue.
					logger.info({
						msg: "Proposal rejected on-chain, another worker proposed a better solution",
						redeemId: req.redeem_id,
						txDigest: result.digest,
						error: errorMessage,
					});
					await this.storage.markRedeemProposed(req.redeem_id, [], 0);
				} else {
					// Unexpected contract error: do not advance redeem state.
					// Surface the error so the catch block can handle it and allow retry.
					logger.error({
						msg: "Proposal rejected on-chain due to unexpected contract error",
						redeemId: req.redeem_id,
						txDigest: result.digest,
						error: errorMessage,
					});
					throw new Error(
						`Redeem proposal transaction failed with unexpected contract error: ${errorMessage}`,
					);
				}
			}
		} catch (e: unknown) {
			// Network or unexpected error: leave as pending so next cron retry.
			logError(

```

If the contract exposes a more precise error code (e.g. via `effects.status.error` being a structured string or enum like `"BetterRedeemProposalExists"`), update the `isBetterProposalError` predicate to match that exact value instead of using a `toLowerCase().includes("better proposal")` heuristic.
</issue_to_address>

### Comment 2
<location> `packages/sui-indexer/src/redeem-sui-client.ts:116-121` </location>
<code_context>
 	}

-	async proposeRedeemUtxos(args: ProposeRedeemCall): Promise<string> {
+	async proposeRedeemUtxos(
+		args: ProposeRedeemCall,
+	): Promise<{ success: boolean; digest: string | null }> {
</code_context>

<issue_to_address>
**suggestion (bug_risk):** The new return shape drops the detailed on-chain error, which could be useful for callers.

This change used to surface `result.effects?.status.error` via an exception, but now only returns `{ success, digest }`, losing the on-chain error detail. Consider adding `error: string | null` to the return type so callers can distinguish failure reasons and log richer context.

Suggested implementation:

```typescript
	proposeRedeemUtxos(
		args: ProposeRedeemCall,
	): Promise<{ success: boolean; digest: string | null; error: string | null }>;

```

```typescript
	async proposeRedeemUtxos(
		args: ProposeRedeemCall,
	): Promise<{ success: boolean; digest: string | null; error: string | null }> {
		const tx = new Transaction();

		tx.add(

```

To fully implement the suggestion and preserve the on-chain error context, you should:
1. Locate where the transaction is executed in `proposeRedeemUtxos` (e.g. `const result = await this.client.signAndExecuteTransactionBlock(...)` or similar).
2. When the transaction succeeds:
   - Return `{ success: true, digest: result.digest, error: null }`.
3. When the transaction fails:
   - Capture the on-chain error string (previously surfaced via `result.effects?.status.error` or via a thrown error) and return something like
     `{ success: false, digest: result.digest ?? null, error: result.effects?.status.error ?? error.message ?? null }`.
4. Ensure any callers that previously relied on exceptions now read the `error` field when `success === false`, and update their typing accordingly.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Signed-off-by: Rayane Charif <rayane.charif@gonative.cc>
Signed-off-by: Rayane Charif <rayane.charif@gonative.cc>
Signed-off-by: Rayane Charif <rayane.charif@gonative.cc>
Copy link
Contributor

@sczembor sczembor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

few comments

Signed-off-by: Rayane Charif <rayane.charif@gonative.cc>
Copy link
Contributor

@sczembor sczembor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quick review

Signed-off-by: Rayane Charif <rayane.charif@gonative.cc>
Copy link
Contributor

@sczembor sczembor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@sczembor sczembor merged commit ce3069c into master Feb 23, 2026
12 checks passed
@sczembor sczembor deleted the rayane/redeem-frontrun-protection branch February 23, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

nBTC Redeem: handle propose solution frontrunning

2 participants