On this page

Recover a transfer

When the destination Router emits MessageFailed, the original transfer is cached by GUID. Your client can offer two permissionless actions: retry destination settlement or restore the asset to the original source sender.

Run these actions on the chain where the message failed.

import { parseAbi } from "viem";

const recoveryAbi = parseAbi([
  "function failedMessages(bytes32 guid) view returns (bool exists, uint32 srcEid, bytes32 sender, uint64 nonce, bytes message, bytes32 messageHash, bytes32 reasonHash, uint256 failedAt)",
  "function minTransferGas() view returns (uint128)",
  "function retryFailedMessage(bytes32 guid)",
  "function quoteRestore(bytes32 guid, bytes options) view returns (uint256)",
  "function restoreFailedMessage(bytes32 guid, bytes options) payable",
]);

Confirm the cached failure

const failure = await destinationClient.readContract({
  address: omnisea,
  abi: recoveryAbi,
  functionName: "failedMessages",
  args: [guid],
});

const exists = failure[0];
if (!exists) throw new Error("This GUID has no recoverable destination failure.");

Refresh this read immediately before displaying or submitting an action. Another account may recover the same GUID first.

Retry settlement

Retry uses the cached payload and requires no LayerZero fee:

const { request } = await destinationClient.simulateContract({
  account,
  address: omnisea,
  abi: recoveryAbi,
  functionName: "retryFailedMessage",
  args: [guid],
});

const retryHash = await walletClient.writeContract(request);
await destinationClient.waitForTransactionReceipt({ hash: retryHash });

Offer retry when the failure was temporary or its cause has been fixed. A successful retry consumes the GUID and completes the original destination mint or unlock.

Quote a restore

A restore sends a new LayerZero message back to the original source chain. Build normal receive options with the current transfer gas floor:

const minimumGas = await destinationClient.readContract({
  address: omnisea,
  abi: recoveryAbi,
  functionName: "minTransferGas",
});
const receiveGas = minimumGas + minimumGas / 5n;
const u128 = (value: bigint) => value.toString(16).padStart(32, "0");
const options = `0x000301002101${u128(receiveGas)}${u128(0n)}` as const;

const restoreFee = await destinationClient.readContract({
  address: omnisea,
  abi: recoveryAbi,
  functionName: "quoteRestore",
  args: [guid, options],
});

The restore quote is the LayerZero fee for the recovery message. Pay it exactly.

Submit restore

const { request } = await destinationClient.simulateContract({
  account,
  address: omnisea,
  abi: recoveryAbi,
  functionName: "restoreFailedMessage",
  args: [guid, options],
  value: restoreFee,
});

const restoreHash = await walletClient.writeContract(request);
const restoreReceipt = await destinationClient.waitForTransactionReceipt({
  hash: restoreHash,
});

Restore consumes the failed GUID and emits FailedMessageRestored from the Router with a new restore GUID. Track that new GUID until it is delivered on the original source chain.

The caller cannot choose the restore recipient. The contract always restores to the sender recorded in the original payload.

React recovery controls

function RecoveryActions({ guid }: { guid: `0x${string}` }) {
  const [pending, setPending] = useState<"retry" | "restore">();

  return (
    <div>
      <button disabled={Boolean(pending)} onClick={() => runRetry(guid, setPending)}>
        Retry delivery
      </button>
      <button disabled={Boolean(pending)} onClick={() => runRestore(guid, setPending)}>
        Restore to sender
      </button>
    </div>
  );
}

Label the outcomes clearly: retry attempts the original destination; restore returns the asset to the original sender and requires another cross-chain delivery.

See Transfer lifecycle for state transitions and Track a transfer for GUID tracking.

Introducing Omnipad-Launch tokens between chains