Agent documentation index: llms.txt. Markdown versions of documentation pages are available by appending .md to the page URL.
Skip to main content

Recover Stuck Mint Transaction

This guide walks through recovering FXRP when a smart-account direct mint payment is stuck at the Core Vault — typically because executeDirectMintingWithData reverted on Flare or the executor never submitted the FDC proof.

The recovery path uses the memo opcode 0xE0 (Skip memo) to mark the stuck XRPL transaction ID, then resubmits the original payment so FXRP is minted to the personal account without running the original custom instruction.

Read the Custom Instruction TypeScript guide first if you are not familiar with the 0xFE hash-commitment flow.

The full code is available on GitHub.

info

The code in this guide is set up for the Coston2 testnet. Despite that, we refer to the network as Flare and its currency as FLR, rather than Coston2 and C2FLR.

Recovery Conditions

Recovery is appropriate when:

  • An XRPL Payment to the Core Vault carried a smart-account memo (0xFE or 0xFF), but no FXRP was minted on Flare.
  • The underlying XRP is still at the Core Vault, and the stuck transaction ID is not yet recorded as used on-chain (isTransactionIdUsed returns false).

Recovery does not apply when the stuck payment was already finalized on Flare — in that case, 0xE0 recovery will fail at diagnosis.

Recovery Process

Recovery mints FXRP to your personal account, not native XRP back on XRPL. The stuck payment's XRP (minus fees) is converted to FXRP; the original user operation is skipped.

To move value back to XRPL after recovery:

The recovery payment itself also mints a small amount of FXRP (default 1 net XRP) because fee-only direct mints revert on-chain — the 0xE0 flag must ride on a payment with a positive net mint amount.

Flow Diagram

The example script runs all steps end-to-end for demonstration on Coston2.

Memo Layout

The 0xE0 skip-memo instruction uses the same 42-byte header shape as 0xFE and 0xFF:

[0xE0 | walletId(1B) | executorFeeUBA(8B) | targetTxId(32B)]

sendSkipMemoInstruction encodes this memo and sends an XRPL Payment to the direct minting payment address:

skipMemo = await sendSkipMemoInstruction({
label: "skip-memo",
targetXrplTxHash: stuckXrplTxHash,
personalAccount,
xrplClient,
xrplWallet,
recoveryNetMintAmountXrp: 1,
});

The gross XRP amount equals the net mint plus minting and executor fees, computed by computeDirectMintingPaymentAmountXrp.

No destination tags

XRPL payments targeting smart accounts must not use a destination tag.

Finalize the Recovery Payment

The executor fetches an FDC XRPPayment proof for the recovery payment and calls executeDirectMintingWithData with empty _data:

({ receipt: recoveryReceipt } = await executeDirectMintingWithData({
xrplTransactionHash: skipMemo.xrplTransactionHash,
data: "0x",
value: 0n,
xrplClient,
label: "skip-memo-executor",
reuseExistingMint: true,
}));

On success, the receipt contains an IgnoreMemoSet event tying the personal account to the stuck transaction ID:

const ignoreMemoSet = findIgnoreMemoSet(
recoveryReceipt,
personalAccount,
targetTxId,
);

The recovery payment also mints FXRP (the small net amount from step 1) to the personal account.

Resubmit the Stuck Payment

With ignoreMemo set, the executor resubmits the original stuck XRPL payment:

const { receipt: stuckReceipt } = await executeDirectMintingWithData({
xrplTransactionHash: stuckXrplTxHash,
data: stuckUserOpData ?? "0x",
value: 0n,
xrplClient,
label: "stuck-retry",
reuseExistingMint: true,
});

The _data parameter depends on how the stuck payment was encoded:

Stuck flowSTUCK_USER_OP_DATA_data on retry
0xFE (hash-commitment)Required — ABI-encoded PackedUserOperation bytes from the original user-side helperSame bytes
0xFF (inline memo)Not required"0x" is sufficient on Coston2

Because ignoreMemo is active, the controller mints FXRP without dispatching the original user operation.

Relayer Edge Cases

The script handles two race conditions common on testnet:

  1. Recovery payment already finalized — if a relayer minted the 0xE0 payment while you were waiting, isStuckTransactionIdUsed returns true for the recovery tx ID and the script loads the existing receipt via findDirectMintingReceiptForTransactionId instead of resubmitting.

  2. Stuck payment finalized during recovery — if a relayer finalized the original stuck payment after IgnoreMemoSet was recorded, step 3 loads that receipt instead of calling executeDirectMintingWithData again.

Both paths use reuseExistingMint: true so PaymentAlreadyConfirmed errors from duplicate submissions are handled gracefully.

Running the Script

Clone flare-viem-starter, configure .env, and run:

STUCK_XRPL_TX_HASH=<your-stuck-tx-hash> pnpm run script src/recover-direct-mint-transaction.ts

For a 0xFE stuck payment, also pass the original user-operation bytes:

STUCK_XRPL_TX_HASH=<hash> \
STUCK_USER_OP_DATA=<packed-user-op-hex> \
pnpm run script src/recover-direct-mint-transaction.ts

If you already sent the 0xE0 recovery payment, resume from step 2:

STUCK_XRPL_TX_HASH=<stuck-hash> \
RECOVERY_XRPL_TX_HASH=<recovery-hash> \
pnpm run script src/recover-direct-mint-transaction.ts

Full Script

The repository with the example is available on GitHub. Helper functions live in the src/utils directory.

src/recover-direct-mint-transaction.ts
src/recover-direct-mint-transaction.ts
import { Client, Wallet } from "xrpl";
import {
diagnoseStuckDirectMint,
executeDirectMintingWithData,
findDirectMintingReceiptForTransactionId,
findIgnoreMemoSet,
getPersonalAccountAddress,
isStuckTransactionIdUsed,
logPersonalAccountFxrpCredit,
normalizeXrplTransactionId,
sendSkipMemoInstruction,
} from "./utils/smart-accounts";
import {
computeDirectMintingPaymentAmountXrp,
getFxrpBalance,
} from "./utils/fassets";
import { getXrpBalance } from "./utils/xrpl";

// Recovery after a failed direct mint using the 0xE0 (skip memo) opcode.
//
// Canonical on-chain flow:
// 1. USER SIDE — XRPL payment with 0xE0 memo targeting the stuck tx ID.
// 2. EXECUTOR SIDE — finalize the recovery payment → IgnoreMemoSet.
// 3. EXECUTOR SIDE — finalize the stuck payment; ignoreMemo skips memo execution.
//
// Recovery mints FXRP to your personal account — not native XRP. To get XRP
// back on XRPL, redeem FXRP via standard FAssets instructions (0x02) or
// AssetManager.redeem after recovery completes.
//
// Required env: STUCK_XRPL_TX_HASH
// Optional env: STUCK_USER_OP_DATA (required for 0xFE stuck payments), RECOVERY_XRPL_TX_HASH

// Net XRP for the 0xE0 recovery payment.
const RECOVERY_NET_MINT_XRP = 1;
// XRPL wallet seed (set in .env when running via `pnpm run script`).
const XRPL_SEED = process.env.XRPL_SEED!;

async function main({
recoveryNetMintAmountXrp,
xrplSeed,
}: {
recoveryNetMintAmountXrp: number;
xrplSeed: string;
}) {
const stuckXrplTxHash = process.env.STUCK_XRPL_TX_HASH?.trim();
if (!stuckXrplTxHash) {
throw new Error(
"STUCK_XRPL_TX_HASH is required — set it to the XRPL transaction hash of the unminted direct-mint payment.",
);
}

const stuckUserOpData = process.env.STUCK_USER_OP_DATA?.trim() as
| `0x${string}`
| undefined;

const xrplClient = new Client(process.env.XRPL_TESTNET_RPC_URL!);
const xrplWallet = Wallet.fromSeed(xrplSeed);

const [personalAccount, recoveryPaymentAmountXrp] = await Promise.all([
getPersonalAccountAddress(xrplWallet.address),
computeDirectMintingPaymentAmountXrp({
netMintAmountXrp: recoveryNetMintAmountXrp,
}),
]);
console.log("Personal account address:", personalAccount, "\n");
console.log("Stuck XRPL transaction hash:", stuckXrplTxHash, "\n");

if (!stuckUserOpData) {
console.warn(
"STUCK_USER_OP_DATA not set — assuming 0xFF stuck flow or memo already skipped. " +
"For 0xFE stuck payments, set STUCK_USER_OP_DATA to the original PackedUserOperation bytes.\n",
);
}

const balanceBefore = await getFxrpBalance(personalAccount);
console.log("FXRP balance before recovery:", balanceBefore, "\n");

// --- Step 0: Diagnose -----------------------------------------------------
const diagnosis = await diagnoseStuckDirectMint({
stuckXrplTxHash,
personalAccount,
});
if (diagnosis.transactionIdUsed) {
throw new Error(
`Stuck transaction ${diagnosis.targetTxId} is already minted on Flare — 0xE0 recovery does not apply.`,
);
}

const xrpBalance = await getXrpBalance(xrplWallet.address, xrplClient);
console.log("XRPL wallet XRP balance:", xrpBalance, "\n");
const existingRecoveryXrplTxHash = process.env.RECOVERY_XRPL_TX_HASH?.trim();
if (!existingRecoveryXrplTxHash && xrpBalance < recoveryPaymentAmountXrp) {
throw new Error(
`Insufficient XRP for recovery payment on ${xrplWallet.address}: ` +
`have ${xrpBalance} XRP, need ${recoveryPaymentAmountXrp} XRP`,
);
}

console.log("=== RECOVERY: 0xE0 skip-memo flow ===\n");

// --- Step 1: USER SIDE — 0xE0 skip-memo payment -------------------------
let skipMemo: { xrplTransactionHash: string; targetTxId: `0x${string}` };

if (existingRecoveryXrplTxHash) {
console.log(
"Using existing recovery XRPL payment from RECOVERY_XRPL_TX_HASH:",
existingRecoveryXrplTxHash,
"\n",
);
skipMemo = {
xrplTransactionHash: existingRecoveryXrplTxHash,
targetTxId: normalizeXrplTransactionId(stuckXrplTxHash),
};
} else {
skipMemo = await sendSkipMemoInstruction({
label: "skip-memo",
targetXrplTxHash: stuckXrplTxHash,
personalAccount,
xrplClient,
xrplWallet,
recoveryNetMintAmountXrp,
});
}

// --- Step 2: EXECUTOR SIDE — finalize recovery payment --------------------
const targetTxId = skipMemo.targetTxId;
const recoveryPaymentTxId = normalizeXrplTransactionId(
skipMemo.xrplTransactionHash,
);
const skipMemoExecutorLabel = "skip-memo-executor";
let recoveryReceipt;

if (await isStuckTransactionIdUsed(recoveryPaymentTxId)) {
console.log(
"Recovery XRPL payment already minted on Flare (likely by a relayer) — loading existing receipt.\n",
);
recoveryReceipt = await findDirectMintingReceiptForTransactionId(
recoveryPaymentTxId,
{
label: skipMemoExecutorLabel,
},
);
} else {
({ receipt: recoveryReceipt } = await executeDirectMintingWithData({
xrplTransactionHash: skipMemo.xrplTransactionHash,
data: "0x",
value: 0n,
xrplClient,
label: skipMemoExecutorLabel,
reuseExistingMint: true,
}));
}

const ignoreMemoSet = findIgnoreMemoSet(
recoveryReceipt,
personalAccount,
targetTxId,
);
console.log("IgnoreMemoSet event:", ignoreMemoSet, "\n");

logPersonalAccountFxrpCredit({
label: skipMemoExecutorLabel,
receipt: recoveryReceipt,
personalAccount,
xrplTransactionId: recoveryPaymentTxId,
});

// A relayer may have finalized the stuck payment while we were recovering.
const stillStuck = !(await isStuckTransactionIdUsed(diagnosis.targetTxId));
if (!stillStuck) {
console.log(
"Stuck transaction was minted on Flare during recovery (likely by a relayer). " +
"Loading mint receipt instead of re-submitting.\n",
);
const stuckReceipt = await findDirectMintingReceiptForTransactionId(
diagnosis.targetTxId,
{
label: "stuck-retry",
},
);
logPersonalAccountFxrpCredit({
label: "stuck-retry",
receipt: stuckReceipt,
personalAccount,
xrplTransactionId: diagnosis.targetTxId,
});
} else {
// --- Step 3: EXECUTOR SIDE — re-submit original stuck payment ---------------
// ignoreMemo is set — memo execution is skipped; _data can be empty on Coston2.
const stuckExecutorLabel = "stuck-retry";
const { receipt: stuckReceipt } = await executeDirectMintingWithData({
xrplTransactionHash: stuckXrplTxHash,
data: stuckUserOpData ?? "0x",
value: 0n,
xrplClient,
label: stuckExecutorLabel,
reuseExistingMint: true,
});

logPersonalAccountFxrpCredit({
label: stuckExecutorLabel,
receipt: stuckReceipt,
personalAccount,
xrplTransactionId: diagnosis.targetTxId,
});
}

// --- Step 4: Verify -------------------------------------------------------
const balanceAfter = await getFxrpBalance(personalAccount);
console.log("FXRP balance after recovery:", balanceAfter, "\n");
console.log("FXRP recovered:", balanceAfter - balanceBefore, "\n");
}

void main({
recoveryNetMintAmountXrp: RECOVERY_NET_MINT_XRP,
xrplSeed: XRPL_SEED,
})
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Expected Output
Personal account address: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F

Stuck XRPL transaction hash: FEAC1ABDB81809293E023DB9345715FA3A27949B23132AB9A2417D8F99A876E9

FXRP balance before recovery: 0

--- Stuck direct mint diagnosis ---
targetTxId: 0xfeac1abdb81809293e023db9345715fa3a27949b23132ab9a2417d8f99a876e9
isTransactionIdUsed: false
current memo nonce: 58
pinned executor: 0x0000000000000000000000000000000000000000
Transaction not yet minted on Flare — recovery via 0xE0 is applicable.

XRPL wallet XRP balance: 1000

=== RECOVERY: 0xE0 skip-memo flow ===

[skip-memo] 0xE0 skip-memo targeting: 0xfeac1abdb81809293e023db9345715fa3a27949b23132ab9a2417d8f99a876e9

[skip-memo] recovery payment amount (XRP, net mint 1 + fees): 1.2

[skip-memo] recovery XRPL transaction hash: A1B2C3D4E5F6...

[skip-memo-executor] FXRP credited to personal account: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F
[skip-memo-executor] Amount (UBA): 1000000

IgnoreMemoSet event: { ... }

[stuck-retry] FXRP credited to personal account: 0xFd2f0eb6b9fA4FE5bb1F7B26fEE3c647ed103d9F
[stuck-retry] Amount (UBA): 10000000

FXRP balance after recovery: 11000000

FXRP recovered: 11000000

Exact fee and amount values depend on live AssetManagerFXRP parameters.

What's next