Recovering a stalled transfer
How to finish a bridge whose burn already broadcast but whose mint has not, using the raw primitives.
The SDK is stateless. It does not persist anything. If your process dies between the burn and the mint, resuming is your job. Fortunately every piece you need is already exposed.
What you need to remember
To finish a stalled transfer you need three things:
- The source
NetworkId. Iris looks up messages per source domain. - The burn transaction hash. Iris identifies the message by it.
- The destination
TokenWithChainDetailsand the recipient address. These build the mint tx.
Persist these however you like: localStorage, IndexedDB, a query string, a backend table, a plain JSON blob. The SDK does not care.
type StalledTransfer = {
sourceId: NetworkId;
destinationId: NetworkId;
burnTxHash: string;
recipient: string;
};
localStorage.setItem("pending-bridge", JSON.stringify({
sourceId: NetworkId.BASE_SEPOLIA,
destinationId: NetworkId.STELLAR_TESTNET,
burnTxHash,
recipient,
}));Resuming later
Read the record, fetch the attestation, build the receive tx, sign, broadcast. That is the whole flow.
import { ZetHubBridge, NetworkId, AssetSymbol } from "@zethub/bridge";
const raw = localStorage.getItem("pending-bridge");
if (!raw) return; // nothing to resume
const stalled = JSON.parse(raw) as StalledTransfer;
const sdk = new ZetHubBridge({ environment: Environment.TESTNET });
// 1. Look up the destination token
const chains = await sdk.chainDetailsMap();
const destinationToken = chains[stalled.destinationId].tokens.find(
(t) => t.symbol === AssetSymbol.USDC,
)!;
// 2. Wait for Iris (or fetch once if you have your own polling loop)
const att = await sdk.attestation.waitFor(
stalled.sourceId,
stalled.burnTxHash,
);
// 3. Build and broadcast the mint
const mintTx = await sdk.bridge.rawTxBuilder.receive({
destinationToken,
toAccountAddress: stalled.recipient,
message: att.message,
attestation: att.attestation,
});
await mySignAndBroadcast(mintTx);
// 4. Clean up
localStorage.removeItem("pending-bridge");That is it. No SDK "resume" method, no bespoke persistence layer, no Transfer records to maintain.
Fetching on a schedule instead of waiting
If your app checks in periodically (a cron job, a background worker, a page the user reopens every few minutes), do not use waitFor. It blocks. Use fetch and let your scheduler handle the retry.
try {
const att = await sdk.attestation.fetch(stalled.sourceId, stalled.burnTxHash);
// ready, build and broadcast the mint
} catch (err) {
if (err instanceof BridgeError && err.code === "ATTESTATION_TIMEOUT") {
return; // still pending, try again next tick
}
throw err;
}What about approve and burn?
Same rule: if you built an approve or burn tx but never broadcast it, the SDK cannot resume that on your behalf. Rebuild the same call with sdk.bridge.rawTxBuilder.approve or .send and try again. The Iris fee quote might drift between rebuilds, which is exactly why we recompute it every time you call send.