Introduction

Refunds

What happens when a V5 transfer does not fill, and how to track the refund.

A transfer is refunded when it does not fill before its deadline. This is rare — the competitive relayer network fills most transfers in seconds — but it is worth handling, because the timescale is very different from a fill.

Know before you sign

Every quote carries a refundPolicy describing expected refund behaviour for that route. It is advisory, not binding — it tells you what to expect before the user commits, not what happened.

It is discriminated by supported:

// Refundable route
{
  "supported": true,
  "token": { "chainId": 42161, "address": "0xaf88..." },
  "refundAddress": "0xYourAddress",
  "expectedSeconds": 5400
}

// Irreversible route
{
  "supported": false,
  "reason": "irreversible_rail",
  "message": "This route cannot be reversed once submitted."
}

When supported is false, reason is one of irreversible_rail, permanent_deposit_address or destination_action_finalised.

Check refundPolicy.supported before asking the user to sign. On a route where it is false, there is no recovery path once the authorization is submitted. message is written to be shown to an end user as-is.

expectedSeconds is measured from the fill deadline and is a best-effort estimate, not an SLA.

The refund lifecycle

The transfer fails to fill

No relayer fills before the deadline. status.state becomes failed with status.reason of expired. Funds are still escrowed.

Bundle settlement

The expired transfer is included in the next settlement bundle. Bundles are proposed roughly every 1.5 hours and must clear a challenge period via UMA's Optimistic Oracle.

Refund execution

After the challenge period the refund root reaches the target chain and the refund executes on-chain. status.state becomes refunded with status.reason of refund_confirmed.

Refunds are not instant. Bundle intervals, the challenge period and canonical bridge delays together mean a refund can take several hours. Do not tell users to expect their money back immediately.

Tracking a refund

Refunds surface on the same transfer you were already tracking — there is no separate endpoint. Once a refund leaf has executed, transfer.refund is populated with message, chain, amount and refundAddress. It is absent until then.

wait-for-refund.ts
const BASE = "https://api.staging.across.to";

async function waitForRefund(orderId: string) {
  const intervalMs = 60_000;  // refunds take hours — poll once a minute
  const maxAttempts = 360;    // give up after ~6 hours

  for (let i = 0; i < maxAttempts; i++) {
    const transfer = await (await fetch(`${BASE}/v1/transfers/${orderId}`)).json();
    const { state, reason, description } = transfer.status;

    switch (state) {
      case "completed":
        return { outcome: "filled" };            // filled after all
      case "refunded":
        return { outcome: "refunded", refund: transfer.refund };
      case "failed":
        console.log(`Awaiting refund (${reason}): ${description}`);
        break;
      default:
        console.log(`Still ${state} / ${reason}`);
    }

    await new Promise((r) => setTimeout(r, intervalMs));
  }

  throw new Error("Refund polling timed out");
}

Poll refunds on a 60-second interval, not the 10 seconds used for fill tracking. Refunds move on the order of hours, so faster polling only costs you requests.

The refund transaction also appears in transfer.transactions as the entry with type: "refund".

Next

On this page