# Integrate Float with your AI

Give your AI coding assistant **the prompt below** and it builds Float into your app or website end to end. It first asks how you want to integrate:

- **API** — your own swap screen in your branding, built on Float's API with **your API key**: a server-side proxy that holds the key, order creation, the deposit step, status tracking, refunds and webhooks.
- **Widget** — Float's ready-made swap screen embedded on your page with **your referral code**: no API key, no backend, live in minutes.

- Get your API key: [https://www.myfloat.cc/partners](https://www.myfloat.cc/partners) → **API for developers** → **Create new API key**.
- The AI reads: [https://www.myfloat.cc/docs/api](https://www.myfloat.cc/docs/api) (Markdown: [/docs/api.md](https://www.myfloat.cc/docs/api.md)), [https://www.myfloat.cc/openapi.json](https://www.myfloat.cc/openapi.json), and this page (Markdown: [/docs/ai.md](https://www.myfloat.cc/docs/ai.md)).
- Starter code: [float-widget-starter.zip](https://www.myfloat.cc/downloads/float-widget-starter.zip) (Next.js App Router), also printed in full [below](#c-reference-implementation).

## The prompt

Copy this into your AI assistant (Claude Code, Cursor, Copilot, …) from the root of your project. Do **not** paste your API key into the prompt — the assistant asks for it and stores it in a server-side environment variable.

```text
You are integrating Float (https://www.myfloat.cc) into my product. Float swaps Canton Coin (CC)
to and from ~2,000 assets at the best rate across its liquidity partners, and I earn 0.2% of the
CC side of every order opened with my API key or my referral code.

Step 1. Read these first, completely, and treat them as the source of truth for every endpoint,
field, state and error code:
  - https://www.myfloat.cc/docs/api.md   (the API reference)
  - https://www.myfloat.cc/openapi.json  (OpenAPI 3.1 spec)
  - https://www.myfloat.cc/docs/ai.md    (the reference widget spec and starter code)
  - https://www.myfloat.cc/llms.txt      (index of Float's docs, with the key facts)
  - https://www.myfloat.cc/downloads/float-widget-starter.zip  (the Next.js starter to adapt)

Step 2. Before writing any code, ask me these questions, ONE AT A TIME, and wait for each answer:
  1. Integration type: "How do you want to integrate Float?
       (A) API — your own swap screen in your app, built on Float's API with your API key: full
           control of the look and flow; needs a small server-side proxy.
       (B) Widget — Float's ready-made swap screen embedded on your page with your referral code:
           no API key, no backend, live in minutes."
     My answer decides everything below. If I am unsure, explain the trade-off in two lines and
     recommend (A) when my app has a backend, (B) for a static site or no-code.
  2. Branding: "What's your website URL or brand kit (logo, colours, fonts)?"
     Use it to match my branding: read my site's colours and fonts (or the kit) and set them
     as CSS variables — for (A) the whole widget, for (B) the section, heading and buttons around
     the embed (Float's screen inside the iframe keeps Float's own look).
  3. For (A) — API key: "Paste your Float API key from the partner dashboard."
     Store it ONLY in a server-side environment variable named FLOAT_API_KEY (.env.local or my
     host's secret store). Never put it in client code, a NEXT_PUBLIC_/VITE_ variable, a URL,
     logs, or the repository. Add .env* to .gitignore if needed.
     For (B) — referral code: "Paste your Float referral code (partner dashboard → Referral
     links)." It is public and safe in client code; never ask me for an API key in (B).
  4. Ideas (optional, I may skip): "Any ideas for how you want Float in your product?"
  5. Placement: "Where in your app should it live?" (a page, a modal, a tab…), or I describe it.

Step 3, if I chose (A) API. Build the integration end to end:
  - Set up the repo or app structure as needed for my stack.
  - A small server-side proxy route that holds FLOAT_API_KEY and calls Float's /v1 API
    (base https://api.myfloat.cc/v1): GET /assets, POST /quote,
    POST /order (with an Idempotency-Key per intended order), GET /order/{claimToken},
    POST /order/{claimToken}/refund-address. Allowlist exactly these paths. The browser only
    ever calls my proxy — Float's API is server-side only and does not allow browser calls
    from my domain.
  - The UI, following the REFERENCE WIDGET spec on https://www.myfloat.cc/docs/ai.md exactly
    (two tabs Get CC / Withdraw CC, token picker, live quote and routes, Exchange, deposit step
    with countdown and QR, status tracking, every state and error message), in my branding,
    usable at 375 px wide and with the keyboard.
  - A webhook receiver in my backend (e.g. POST /api/float-webhook) that verifies the
    Float-Signature header (t=…,v1=…: HMAC-SHA256 with the endpoint secret over "<t>.<raw body>",
    constant-time compare, accept either v1 during a rotation, reject t older than 5 minutes) on
    the RAW body before parsing, de-duplicates on the event id, answers 2xx within 10 s, and
    handles order.state_changed (data.order is the public order object), payout.updated and
    webhook.test. Register it with my key via POST /v1/webhooks once the app is deployed on a
    public https URL; store the returned secret (shown once) as the server-side env var
    FLOAT_WEBHOOK_SECRET; then send a test event with POST /v1/webhooks/{id}/test. The widget
    still polls GET /order/{claimToken} for the order on screen (every 10–30 s, 5 s while
    state is preparing_deposit_address) and stops at a final state.
  - Tests, or a manual test checklist, and a README (how to run, where the key goes).

Step 3, if I chose (B) Widget. Build the embed exactly as section "10. Embed widget" of
https://www.myfloat.cc/docs/api.md describes:
  - The iframe https://www.myfloat.cc/bridge?embed=1&ref=<my referral code>, plus dir / asset /
    amount when my placement calls for it (e.g. a "Withdraw CC" entry point → dir=out). Give it
    a title, width 100%, no border, loading="lazy".
  - The height listener, accepting messages only when e.origin === "https://www.myfloat.cc"
    exactly and e.data.source === "float-widget", so the iframe never scrolls inside my page.
  - A section in my branding around it at my chosen placement, usable at 375 px wide, plus a
    plain referral link (https://www.myfloat.cc/bridge?ref=<code>) as a fallback button.
  - No API key, no proxy and no webhook: orders are attributed by the referral code and I see
    them and my earnings in the partner dashboard.

Step 4. Check it and hand over. For (A): run it, load the asset list and a live quote through
the proxy (no real order), check 375 px and keyboard use, then tell me what you built, which env
vars to set on my host, and how to register the webhook once the app is deployed. For (B): load
the page, check the embed shows, resizes with its content and works at 375 px, then tell me what
you built and where.

Rules:
  - Use rateType "float" by default.
  - Amounts are decimal strings: never do money arithmetic in floating point; show amountIn
    exactly as returned; round received amounts down for display.
  - Render the API's error sentence as text, never as HTML (no innerHTML); build UI from data
    with textContent / your framework's text binding.
  - Identify an asset by symbol + network (the pair), never by a ticker suffix like "-SOL". Omit
    network (do not send null) when the asset row has none.
  - Withdraw CC (CC → X): the user sends exactly amountIn CC to depositAddress — no memo, ever.
    The PAYOUT side may need a destination tag / memo: when the asset row or the quote says
    destinationExtraIdNeeded, show the field (label: destinationExtraIdName) with the warning
    "Sending to an exchange? Add your destination tag/memo, or the funds may be lost." and send
    it as destinationExtraId on the quote and the order.
    Get CC (X → CC): if depositExtraId is not null, the user must include it as the memo/tag.
  - Never create a real order while testing unless I say so; quotes and the asset list are safe.
  - If the docs and the spec ever disagree with what the API returns, trust the API and tell me.

You may start from Float's starter (Next.js App Router), printed in full on
https://www.myfloat.cc/docs/ai.md and downloadable from
https://www.myfloat.cc/downloads/float-widget-starter.zip — adapt it to my stack and branding.
```

## B. Reference widget spec

Reproduce Float's own swap screen ([myfloat.cc/bridge](https://www.myfloat.cc/bridge)) in the partner's branding. Everything below is what Float's site does; use the same copy.

### Layout

- One card, max about 560 px wide, centred; at **375 px** it fills the width with a 16 px gutter and **nothing scrolls sideways** (long addresses wrap).
- Top: two tabs, **Get CC** (X → CC, `direction: "in"`) and **Withdraw CC** (CC → X, `direction: "out"`). Switching tabs clears the address fields and re-quotes.
- Then two "legs": **You deposit** (token button + amount input) and **You receive** (token + the quoted amount). On Get CC the token button is on the deposit leg and CC is fixed on the receive leg; on Withdraw CC the reverse. Each leg shows "on {network name}" for the non-CC side.
- **Rate:** two options, **Float rate** (default, selected) — "Follows the market until your deposit confirms. Usually reaches more venues." — and **Fixed rate** — "Locked for the whole order. Same fee either way."

### Get CC (X → CC)

1. **Token:** the user chooses the token they pay with in the token picker ([spec below](#token-picker)).
2. **Amount:** typing re-quotes after 500 ms (`POST /quote` with `from`, `network` from the picker row, `amount`, `rateType`). Show "Asking every partner for a quote…" while waiting, then the receive amount (rounded down) and the routes.
3. **Routes:** "Choose your route" — the best first, tagged **BEST RATE**, then `alternatives`, each with its `note` ("0.37% less than The Change" — display text, never parse it) and amount. A route with `needsRefundAddress` is tagged **NEEDS REFUND ADDRESS**. Show the fee (`totalFeeBps` as a %), the minimum (`minIn`, rounded up), any `warning`, and a collapsible "Could not quote" list from `unavailable`. On Fixed rate, add: "Fewer partners offer a fixed rate. Floating rate compares more routes." with a **Compare floating** button.
4. **Address:** "Your Canton party ID" (placeholder `yourname::1220e8c7…`), hint "CC settles straight to this party ID on Canton." Validate the shape (`name::` + 16+ hex) before sending; on a bad value say "That does not look like a Canton party ID. It should look like name::1220abcd…".
5. **Refund address** — only when the selected route needs one: "Your {TOKEN} refund address", hint "{Partner} returns your deposit here if the swap cannot complete."
6. **Exchange** button → `POST /order` (through the proxy) with `venue` = the selected route and a fresh `Idempotency-Key` per intended order. Button copy: "Enter an amount" / "Asking every venue…" / "No route at this amount" / "Exchange" / "Opening the order…". Under it: "Creating an order does not move funds. You send the deposit yourself."
7. **Deposit step:** "Send" **exactly `amountIn` {TOKEN} on {network}**, "To this deposit address" — the partner's `depositAddress` with **Copy** and a **QR** drawn in the browser from that address; if `depositExtraId` is not null a highlighted "**Memo — required**" box with Copy and "Include this with your transfer. Without it the exchange cannot tell your deposit from anyone else's."; the countdown "**Send within mm:ss** · by {local time} your time" from `depositDeadline` (under 5 minutes: "Less than 5 minutes left — only send if your transfer can go out now.").
8. **Status** until final, with the transactions list ("View" links from `explorerUrl`).

### Withdraw CC (CC → X)

1. **Amount** of CC.
2. **Token and network** to receive, from the token picker.
3. **Quote and routes** as above.
4. **Address:** "Your {TOKEN} address" (placeholder "Paste your {TOKEN} address on {network}"), hint "Double-check the network. Payouts cannot be reversed."
5. **Destination tag / memo:** when the chosen row (or the quote) has `destinationExtraIdNeeded: true`, show a field labelled with `destinationExtraIdName` ("Destination tag" for XRP, else "Memo"), optional, with the warning "**Sending to an exchange? Add your destination tag/memo, or the funds may be lost.**" Send it as `destinationExtraId` on the quote (partners that cannot carry it drop out) and on the order. On the order screen show it under the payout address ("Destination tag: 104729").
6. **Refund party (optional):** "Your Canton party for refunds (optional)", hint "If the swap can't complete, your CC comes back here. Leave empty to refund the wallet you send from — but if you send from an exchange, fill this in." Send it as `refundParty`.
7. **Exchange** → `POST /order`.
8. **Deposit step:** "Send **exactly `amountIn` CC**" "To this deposit address" — Float's `depositAddress` with **Copy** and **QR**, and "**Valid for this order only.** Do not reuse it for another order." (`depositAddressNote`). **No memo** — say "No memo needed." Countdown from `depositDeadline`. Explain: "Less is refunded and the order cancelled; more is processed and the extra refunded."
9. **Status** until final.

### Token picker

- Data: `GET /v1/assets?all=1` once, through the proxy. Exclude `CC`.
- **Popular first** (USDT, USDC, BTC, ETH, SOL, BNB, XRP, TRX, DOGE, LTC, TON, AVAX), then A–Z.
- **Search** "Search by name or ticker": exact ticker, then prefix, then anything.
- **Network filter**: chips (or a column on wide screens) "All networks" + each network with its `networkIcon` and `networkName`, featured first (Ethereum, Solana, Tron, BSC, Bitcoin, Arbitrum, Base, Polygon, Avalanche, Optimism, TON), then by token count.
- Each row: the token `icon` with the `networkIcon` as a small corner badge, the symbol, the name, the network name. Any null or broken image → a **lettered tile** (first letter, brand-tinted circle).
- Keyboard: focus lands in search; ↑/↓ move; Enter picks; Esc closes; it is a `role="dialog"` with `aria-modal`.
- Returns the row: quote with `from`/`to` = `symbol` and `network` = the row's `network` (when it has one).

### States and copy

Drive everything from the order's `state` (and the deposit window):

| When | Title | Body |
|---|---|---|
| quoting | — | "Asking every partner for a quote…" |
| no route (`409 conflict` on quote) | — | the API's `error`, e.g. "That amount is below every venue's minimum. The lowest is 30.0392 USDT." or "No partner can pay XRP out with a destination tag or memo right now…". Button: "No route at this amount". |
| `venue_unavailable` on Exchange | — | the API's `error` ("The Change can't open this swap right now. Pick another route — nothing has been sent."). Tag that route **UNAVAILABLE**, select the next one, and let the user press Exchange again. |
| `awaiting_refund_address` | Add a refund address | "This partner needs a refund address before it gives you a deposit address. Add yours below — nothing is sent or charged until you deposit." (On `409 requote_needed`: "The rate moved: you would now receive {freshOut}." + "Accept and open the order", re-sending with `acceptOut`.) |
| `preparing_deposit_address` | Preparing your deposit address… | "We are creating a deposit address for this order alone. It appears here in a moment — there is nothing to send yet." |
| `awaiting_deposit`, window open | Waiting for your {TOKEN or CC} | "Send the amount shown to the address above. We detect it automatically, and nothing is charged until you do." |
| `awaiting_deposit`, past `depositDeadline` | Deposit window closed | "If you already sent, it will still be processed or refunded automatically. Don't send now." In the grace (before `depositGraceUntil`) add: "A payment already on its way is still accepted for a few more minutes." Hide the address. |
| `confirming` | Get CC: Deposit received · Withdraw CC: Got your CC | "The exchange has seen your deposit and is confirming it." · "Your deposit has arrived with us. We are paying the venue now — you do not need to do anything." |
| `exchanging` | Get CC: Exchange in progress · Withdraw CC: Swapping now | "The exchange has your deposit and is converting it. Your CC lands as soon as it clears." · "The exchange is converting your CC to {TOKEN} and will send it straight to your address." |
| `sending` | Get CC: Sending your CC · Withdraw CC: {TOKEN} on its way | "Your CC is on its way to your Canton party." · "The exchange has your CC and is sending {TOKEN} to your address." |
| `completed` | Done | "{amount} {TOKEN} sent to your address." |
| `refunded` | Refunded | "Your deposit has been returned to {your refund party / the address it came from}." |
| `expired` | Expired | "Nothing was received. Nothing was sent and nothing is owed — start a new order." |
| `failed` | Held up | "Something went wrong after your deposit. Float can see this order and is on it — contact support@myfloat.cc with your order link if you want an update." |

Progress rail: Get CC — "Order created · Your {TOKEN} · Exchanged · CC sent to you · Done"; Withdraw CC — "Order created · Your CC · Sent to the venue · {TOKEN} on its way · Done".

**Every error message:** show the API's `error` sentence as-is in an alert (`role="alert"`) — render it as text, never as HTML (no `innerHTML`) — and branch on `code` as in the [errors table](https://www.myfloat.cc/docs/api#8-errors). Extra client-side copy: rate limit — "Too many requests — try again in {Retry-After} s."; network failure — "Could not reach the server. Check your connection and try again."; bad order link — "That order link did not match an order. Check the link, or start a new order below."

Also: put the `claimToken` in the page URL (`?order=…`) so a reload or a saved link reopens the order, and show an "Order link" with Copy ("It reopens this order any time, even after you close this page."). Final states show **New order** and **Contact support** (support@myfloat.cc).

### Accessibility

Real `<button>`s and `<label>`s; tabs with `role="tab"`/`aria-selected`; the rate choice as a radio group; the status card `role="status"` `aria-live="polite"`; the countdown `role="timer"`; errors `role="alert"`; visible focus rings; the picker fully keyboard-operable; respects `prefers-reduced-motion`.

## C. Reference implementation

A self-contained Next.js App Router starter. It builds on its own (`npm install && npm run build`), quotes against the live API with no key, and opens orders only once `FLOAT_API_KEY` is set. It includes the webhook receiver (`FLOAT_WEBHOOK_SECRET`) and a script that registers it. Download: [float-widget-starter.zip](https://www.myfloat.cc/downloads/float-widget-starter.zip).

### `app/api/float/[...path]/route.ts` — the key-holding proxy

```ts
/**
 * The key-holding proxy: your browser code calls /api/float/…, this route
 * calls Float's partner API with your key, and nothing secret ever reaches
 * the browser.
 *
 *   GET  /api/float/assets?…                       → GET  /v1/assets?…
 *   POST /api/float/quote                          → POST /v1/quote
 *   POST /api/float/order                          → POST /v1/order   (needs FLOAT_API_KEY)
 *   GET  /api/float/order/{claimToken}             → GET  /v1/order/{claimToken}
 *   POST /api/float/order/{claimToken}/refund-address → POST /v1/order/{claimToken}/refund-address
 *
 * Anything else is a 404. The key is read from the FLOAT_API_KEY environment
 * variable on the server — never from the request, and never sent back.
 *
 * Starter-grade guards (see "The proxy's guards" in README.md):
 *   - same origin: a request whose Origin / Referer is another site is refused
 *     (403), and a POST must carry one. Extra allowed origins:
 *     FLOAT_PROXY_ALLOWED_ORIGINS="https://app.example.com,https://example.com";
 *   - a per-IP limit, in this server's memory (FLOAT_PROXY_RATE_LIMIT a minute,
 *     default 60) — one instance only; use your platform's limiter or a shared
 *     store when you run several;
 *   - request bodies over 16 KB are refused (413).
 * They keep casual abuse of YOUR key and YOUR rate limit off this route; they
 * are not a substitute for your own auth if your app has users.
 * Docs: https://www.myfloat.cc/docs/api · spec: https://www.myfloat.cc/openapi.json
 */

import type { NextRequest } from "next/server";

const BASE = (process.env.FLOAT_API_BASE || "https://api.myfloat.cc/v1").replace(/\/+$/, "");
const TOKEN = /^[A-Za-z0-9_-]{1,128}$/;
const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,255}$/;
const MAX_BODY_BYTES = 16 * 1024;
const RATE_LIMIT = Math.max(1, Number(process.env.FLOAT_PROXY_RATE_LIMIT) || 60);
const EXTRA_ORIGINS = (process.env.FLOAT_PROXY_ALLOWED_ORIGINS ?? "")
  .split(",")
  .map((s) => s.trim().replace(/\/+$/, "").toLowerCase())
  .filter(Boolean);

const fail = (status: number, error: string, code: string, headers?: Record<string, string>) =>
  Response.json({ error, code }, { status, headers: { "cache-control": "no-store", ...headers } });

/** This app's own origin, as the browser sees it (behind a proxy: x-forwarded-host / -proto). */
function selfOrigin(req: NextRequest): string {
  const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? req.nextUrl.host).split(",")[0]!.trim();
  const proto = (req.headers.get("x-forwarded-proto") ?? req.nextUrl.protocol.replace(/:$/, "")).split(",")[0]!.trim();
  return `${proto}://${host}`.toLowerCase();
}

/** Where the request came from: the Origin header, else the Referer's origin, else null. */
function callerOrigin(req: NextRequest): string | null {
  const o = req.headers.get("origin");
  if (o && o !== "null") return o.toLowerCase();
  const ref = req.headers.get("referer");
  if (!ref) return null;
  try { return new URL(ref).origin.toLowerCase(); } catch { return null; }
}

/* Requests per IP in the current minute. In memory: per server instance, reset on restart. */
const hits = new Map<string, { n: number; until: number }>();
function overLimit(ip: string): number | null {
  const now = Date.now();
  if (hits.size > 10_000) for (const [k, v] of hits) if (v.until <= now) hits.delete(k);
  const h = hits.get(ip);
  if (!h || h.until <= now) { hits.set(ip, { n: 1, until: now + 60_000 }); return null; }
  h.n += 1;
  return h.n > RATE_LIMIT ? Math.ceil((h.until - now) / 1000) : null;
}
const clientIp = (req: NextRequest) =>
  (req.headers.get("x-forwarded-for") ?? "").split(",")[0]!.trim() || req.headers.get("x-real-ip")?.trim() || "unknown";

type Allowed = { upstream: string; needsKey: boolean };

/** Which Float path a request may reach, or null. An allowlist: the proxy
 *  must not turn into an open relay for whatever path a browser sends. */
function allowed(method: string, parts: string[]): Allowed | null {
  const [a, b, c] = parts;
  if (method === "GET" && parts.length === 1 && a === "assets") return { upstream: "/assets", needsKey: false };
  if (method === "POST" && parts.length === 1 && a === "quote") return { upstream: "/quote", needsKey: false };
  if (method === "POST" && parts.length === 1 && a === "order") return { upstream: "/order", needsKey: true };
  if (method === "GET" && parts.length === 2 && a === "order" && TOKEN.test(b ?? "")) return { upstream: `/order/${b}`, needsKey: false };
  if (method === "POST" && parts.length === 3 && a === "order" && TOKEN.test(b ?? "") && c === "refund-address") {
    return { upstream: `/order/${b}/refund-address`, needsKey: false };
  }
  return null;
}

/* Headers from Float worth handing to the browser. */
const PASS_BACK = ["content-type", "retry-after", "x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset", "idempotent-replayed"];

async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }): Promise<Response> {
  const { path } = await ctx.params;
  const route = allowed(req.method, path ?? []);
  if (!route) return fail(404, "No such endpoint.", "not_found");

  /* Same origin only: this route spends your key and your rate limit. */
  const origin = callerOrigin(req);
  if (origin ? origin !== selfOrigin(req) && !EXTRA_ORIGINS.includes(origin) : req.method === "POST") {
    return fail(403, "This endpoint only answers this site's own pages.", "forbidden_origin");
  }

  const retry = overLimit(clientIp(req));
  if (retry !== null) {
    return fail(429, `Too many requests — try again in ${retry} s.`, "rate_limited", { "retry-after": String(retry) });
  }

  const key = process.env.FLOAT_API_KEY?.trim();
  if (route.needsKey && !key) {
    /* Float would open the order without a key, but it would not be
       attributed to you (no 0.2%) and Idempotency-Key would be refused. */
    return Response.json(
      { error: "FLOAT_API_KEY is not set on the server, so orders are disabled here. Add it to .env.local and restart.", code: "proxy_not_configured" },
      { status: 503 }
    );
  }

  const headers: Record<string, string> = { accept: "application/json" };
  if (key) headers.authorization = `Bearer ${key}`;
  let body: string | undefined;
  if (req.method === "POST") {
    headers["content-type"] = "application/json";
    if (Number(req.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) return fail(413, "The request body is too large.", "payload_too_large");
    body = await req.text();
    if (new TextEncoder().encode(body).length > MAX_BODY_BYTES) return fail(413, "The request body is too large.", "payload_too_large");
    /* One Idempotency-Key per intended order, made by the browser and reused
       on every retry of that order. Float only accepts it with a key. A key
       that is present but malformed is refused here, never silently dropped:
       dropping it would turn a safe retry into a second order. */
    const idem = req.headers.get("idempotency-key");
    if (idem !== null) {
      if (!IDEMPOTENCY_KEY.test(idem)) {
        return fail(400, "Idempotency-Key must be 1–255 visible ASCII characters (use a UUID).", "idempotency_key_invalid");
      }
      if (key) headers["idempotency-key"] = idem;
    }
  }

  const url = BASE + route.upstream + (req.method === "GET" ? req.nextUrl.search : "");
  let res: Response;
  try {
    res = await fetch(url, { method: req.method, headers, body, cache: "no-store", signal: AbortSignal.timeout(30_000) });
  } catch {
    /* No answer (timeout or network). For POST /order that does NOT mean nothing
       opened: the order may exist. Retrying with the same Idempotency-Key and
       body returns it if it did, or opens it once if it did not. */
    return route.upstream === "/order"
      ? fail(504, "Float did not answer in time, so we can't tell whether your order opened. Try again — the retry uses the same order key, so it won't open a second order.", "upstream_timeout")
      : fail(502, "Could not reach Float. Try again in a moment.", "upstream_error");
  }

  const out = new Headers();
  for (const h of PASS_BACK) {
    const v = res.headers.get(h);
    if (v) out.set(h, v);
  }
  out.set("cache-control", route.upstream === "/assets" ? "public, max-age=60" : "no-store");
  return new Response(await res.arrayBuffer(), { status: res.status, headers: out });
}

export const GET = proxy;
export const POST = proxy;
export const dynamic = "force-dynamic";
```

### `components/FloatWidget.tsx` — the swap flow

```tsx
"use client";

/**
 * FloatWidget — Float's swap flow (myfloat.cc/bridge) for your own app.
 *
 *   Get CC       (direction "in"):  any asset → CC
 *   Withdraw CC  (direction "out"): CC → any asset
 *
 * Form → live quote and routes → Exchange (POST /order with an
 * Idempotency-Key) → deposit step (address, exact amount, countdown, QR) →
 * status until a final state. Every call goes through /api/float/… (the
 * key-holding proxy in app/api/float/[...path]/route.ts).
 *
 * Brand it with the CSS variables in app/globals.css (--fw-*).
 * Reference: https://www.myfloat.cc/docs/api · https://www.myfloat.cc/docs/ai
 */

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import TokenPicker, { loadAssets, TokenFace } from "@/components/TokenPicker";
import {
  api, countdown, decimals, FINAL_STATES, looksLikeCantonParty, newIdempotencyKey, showAmount, stateCopy, stepLabels,
  stepOf, venueName, windowOf,
  type Asset, type Direction, type Order, type Quote, type RateType,
} from "@/lib/float";

const CC: Asset = { symbol: "CC", name: "Canton Coin", network: "CANTON", networkName: "Canton", icon: "https://www.myfloat.cc/icons/cc-light.png", networkIcon: null };
/* CC's own logo, no chain badge. */
const CC_FACE = { ...CC, networkName: null };
const DEFAULT_IN = "USDT";
const DEFAULT_OUT = "BTC";
const URGENT_SECONDS = 5 * 60;

type Route = { venue: string; amountOut: string; note: string | null; needsRefundAddress: boolean; totalFeeBps: number | null };

/** The all-in fee of a route, in %. 0 is a real figure ("0%"); null is unknown ("—"). */
function feeText(bps: number | null | undefined): string {
  if (bps == null || !Number.isFinite(bps)) return "—";
  return bps === 0 ? "0%" : `${(bps / 100).toFixed(2)}%`;
}

/** What the payout tag is called on an order (the order object carries the tag, not its name). */
const tagNameOf = (o: Order) => (o.to.toUpperCase() === "XRP" ? "Destination tag" : "Memo");

/** A 400 about the destination tag / memo (e.g. "An XRP destination tag is a whole number…"). */
const isTagError = (status: number, message: string) => status === 400 && /destination tag|memo/i.test(message);

function timezone(): string | undefined {
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return undefined; }
}

function setOrderInUrl(token: string | null) {
  try {
    const u = new URL(window.location.href);
    if (token) u.searchParams.set("order", token); else u.searchParams.delete("order");
    window.history.replaceState(null, "", u.toString());
  } catch { /* history blocked — the order link button still works */ }
}

function Copy({ text, label = "Copy" }: { text: string; label?: string }) {
  const [done, setDone] = useState(false);
  return (
    <button
      type="button"
      className="fw-btn-small"
      onClick={() => {
        void navigator.clipboard?.writeText(text).catch(() => {});
        setDone(true);
        setTimeout(() => setDone(false), 1400);
      }}
    >
      {done ? "Copied" : label}
    </button>
  );
}

function Qr({ value }: { value: string }) {
  const [src, setSrc] = useState<string | null>(null);
  useEffect(() => {
    let alive = true;
    setSrc(null);
    /* Drawn in the browser from the address we were given — never a stock image. */
    void import("qrcode").then((m) => m.toDataURL(value, { margin: 1, width: 240, errorCorrectionLevel: "M" })).then((u) => { if (alive) setSrc(u); }).catch(() => {});
    return () => { alive = false; };
  }, [value]);
  if (!src) return null;
  // eslint-disable-next-line @next/next/no-img-element
  return <img className="fw-qr" src={src} width={120} height={120} alt="QR code of the deposit address" />;
}

export default function FloatWidget() {
  const [dir, setDir] = useState<Direction>("in");
  const [amount, setAmount] = useState("100");
  const [asset, setAsset] = useState<Asset | null>(null);
  const [pickerOpen, setPickerOpen] = useState(false);
  const [rateType, setRateType] = useState<RateType>("float");

  const [q, setQ] = useState<Quote | null>(null);
  const [quoting, setQuoting] = useState(false);
  const [quoteErr, setQuoteErr] = useState<string | null>(null);
  const [routeIdx, setRouteIdx] = useState(0);
  const [down, setDown] = useState<string[]>([]);
  const [downNote, setDownNote] = useState<string | null>(null);

  const [dest, setDest] = useState("");
  const [refundAddress, setRefundAddress] = useState("");
  const [refundParty, setRefundParty] = useState("");
  /* Withdraw CC: the payout address's destination tag / memo, when it needs one. */
  const [tag, setTag] = useState("");
  const [tagDebounced, setTagDebounced] = useState("");
  /* What the last successful quote for this asset said about the tag. Kept when a
     re-quote fails (e.g. a malformed tag is a 400), so the field never vanishes
     under the user's cursor. */
  const [tagMeta, setTagMeta] = useState<{ key: string; needed: boolean; name: string | null } | null>(null);
  /* The last quote error was about the tag: keep the field on screen. */
  const [tagErr, setTagErr] = useState(false);
  /* The tag the current quote was asked with — Exchange only sends a tag the quote has seen. */
  const [quotedTag, setQuotedTag] = useState("");
  /* Bumped to force a fresh quote (e.g. after "New order"). */
  const [quoteNonce, setQuoteNonce] = useState(0);

  const [order, setOrder] = useState<Order | null>(null);
  const [creating, setCreating] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const idemKey = useRef<string | null>(null);
  const [, tick] = useState(0);

  /* Default token per direction, from the live catalogue. */
  useEffect(() => {
    let alive = true;
    let timer: ReturnType<typeof setTimeout> | undefined;
    const want = dir === "in" ? DEFAULT_IN : DEFAULT_OUT;
    const load = (attempt: number) => {
      void loadAssets().then((rows) => {
        if (!alive) return;
        /* Empty means the list could not be loaded (e.g. rate-limited): try again shortly. */
        if (!rows.length && attempt < 5) { timer = setTimeout(() => load(attempt + 1), 3000 * (attempt + 1)); return; }
        setAsset((cur) => cur ?? rows.find((r) => r.symbol === want) ?? rows[0] ?? null);
      });
    };
    load(0);
    return () => { alive = false; if (timer) clearTimeout(timer); };
  }, [dir]);

  /* Reopen an order from ?order=<claimToken>. */
  const [restoring, setRestoring] = useState(false);
  useEffect(() => {
    const token = new URLSearchParams(window.location.search).get("order");
    if (!token) return;
    setRestoring(true);
    void api.get(token).then((r) => {
      setRestoring(false);
      if (r.ok) setOrder(r.data);
      else setErr("That order link did not match an order. Check the link, or start a new order below.");
    });
  }, []);

  const inbound = dir === "in";
  const from = inbound ? asset : CC;
  const to = inbound ? CC : asset;

  /* Withdraw CC: does the payout need a destination tag / memo, and what is it called?
     The asset row says so, or the last quote for this asset did. */
  const tagKey = `${asset?.symbol ?? ""}|${asset?.network ?? ""}`;
  const tagMetaNow = tagMeta?.key === tagKey ? tagMeta : null;
  const tagNeeded = !inbound && (asset?.destinationExtraIdNeeded === true || tagMetaNow?.needed === true);
  const tagName = tagMetaNow?.name ?? asset?.destinationExtraIdName ?? "Memo";
  /* Shown when needed, and whenever a tag is typed or the last error was about it. */
  const showTag = !inbound && (tagNeeded || tag !== "" || tagErr);
  /* Sent (quote and order) only when the asset row or the quote says a tag is needed. */
  const tagForQuote = tagNeeded ? tagDebounced : "";
  const tagForOrder = tagNeeded ? tag.trim() : "";
  /* Typed but not yet quoted: wait for the re-quote before opening the order. */
  const tagPending = !inbound && tagForOrder !== quotedTag;

  /* A new intended order whenever its inputs change: a fresh Idempotency-Key. */
  useEffect(() => { idemKey.current = null; }, [dir, asset, amount, rateType, dest, refundAddress, refundParty, tag, routeIdx]);

  /* The tag is part of the quote (partners that cannot carry it drop out): re-quote when typing stops. */
  useEffect(() => {
    const t = setTimeout(() => setTagDebounced(tag.trim()), 600);
    return () => clearTimeout(t);
  }, [tag]);

  /* Live quote, debounced: each one asks every partner. */
  useEffect(() => {
    setQ(null);
    setQuoteErr(null);
    setQuotedTag("");
    const n = Number(amount);
    if (!asset || !Number.isFinite(n) || n <= 0) return;
    const ac = new AbortController();
    setQuoting(true); // covers the debounce too, so the button never flashes "No route"
    const t = setTimeout(async () => {
      const r = await api.quote(
        {
          direction: dir,
          ...(inbound ? { from: asset.symbol } : { to: asset.symbol }),
          amount: amount.trim(),
          rateType,
          ...(asset.network ? { network: asset.network } : {}),
          ...(!inbound && tagForQuote ? { destinationExtraId: tagForQuote } : {}),
          ...(timezone() ? { timezone: timezone() } : {}),
        },
        ac.signal
      );
      if (ac.signal.aborted) return;
      setQuoting(false);
      if (r.ok) {
        setQ(r.data); setRouteIdx(0); setDown([]); setDownNote(null);
        setQuotedTag(!inbound ? tagForQuote : "");
        setTagErr(false);
        if (!inbound) setTagMeta({ key: tagKey, needed: r.data.destinationExtraIdNeeded === true, name: r.data.destinationExtraIdName ?? null });
      } else {
        setTagErr(!inbound && !!tagForQuote && isTagError(r.status, r.err.error));
        setQuoteErr(r.status === 429 ? `Too many requests — try again in ${r.retryAfter ?? 60} s.` : r.err.error);
      }
    }, 500);
    return () => { clearTimeout(t); ac.abort(); setQuoting(false); };
  }, [dir, asset, amount, rateType, inbound, tagForQuote, tagKey, quoteNonce]);

  const routes: Route[] = useMemo(
    () => (q
      ? [
          { venue: q.venue, amountOut: q.amountOut, note: null, needsRefundAddress: q.needsRefundAddress, totalFeeBps: q.totalFeeBps },
          ...q.alternatives.map((a) => ({ venue: a.venue, amountOut: a.amountOut, note: a.note, needsRefundAddress: a.needsRefundAddress, totalFeeBps: a.totalFeeBps })),
        ]
      : []),
    [q]
  );
  const sel = routes[routeIdx];
  const selDown = !!sel && down.includes(sel.venue);

  /* Poll the order until it is final: every 5 s while its address is being made, else 15 s. */
  useEffect(() => {
    if (!order || FINAL_STATES.includes(order.state)) return;
    const every = order.state === "preparing_deposit_address" ? 5000 : 15000;
    const t = setInterval(() => {
      void api.get(order.claimToken).then((r) => { if (r.ok) setOrder(r.data); });
    }, every);
    return () => clearInterval(t);
  }, [order]);

  /* The countdown re-renders every second from the order's own deadline. */
  useEffect(() => {
    if (order?.state !== "awaiting_deposit") return;
    const t = setInterval(() => tick((n) => n + 1), 1000);
    return () => clearInterval(t);
  }, [order?.state]);

  const flip = (d: Direction) => {
    if (d === dir) return;
    setDir(d);
    setAsset(null);
    setDest("");
    setRefundAddress("");
    setRefundParty("");
    setTag("");
    setTagDebounced("");
    setTagErr(false);
    setErr(null);
    setAmount(d === "in" ? "100" : "1000");
  };

  /* A different asset (or network) is a different payout address: its tag and any error go. */
  const pick = (a: Asset) => {
    setPickerOpen(false);
    if (asset && a.symbol === asset.symbol && (a.network ?? "") === (asset.network ?? "")) return;
    setAsset(a);
    setTag("");
    setTagDebounced("");
    setTagErr(false);
    setErr(null);
  };

  const exchange = useCallback(async () => {
    if (!q || !sel || !asset) return;
    setErr(null);
    /* Never open an order with a tag the quote has not seen: re-quote with it first. */
    if (tagPending) { setTagDebounced(tag.trim()); return; }
    const d = dest.trim();
    if (!d) { setErr(inbound ? "Enter your Canton party ID." : `Enter your ${asset.symbol} address.`); return; }
    if (inbound && !looksLikeCantonParty(d)) {
      setErr("That does not look like a Canton party ID. It should look like name::1220abcd…");
      return;
    }
    if (!inbound && refundParty.trim() && !looksLikeCantonParty(refundParty)) {
      setErr("The refund party should look like name::1220abcd…, or be left empty.");
      return;
    }
    const body = {
      direction: dir,
      ...(inbound ? { from: asset.symbol } : { to: asset.symbol }),
      amount: amount.trim(),
      rateType,
      ...(asset.network ? { network: asset.network } : {}),
      destination: d,
      venue: sel.venue,
      ...(inbound && refundAddress.trim() ? { refundAddress: refundAddress.trim() } : {}),
      ...(!inbound && refundParty.trim() ? { refundParty: refundParty.trim() } : {}),
      ...(!inbound && tagForOrder ? { destinationExtraId: tagForOrder } : {}),
      ...(timezone() ? { timezone: timezone() } : {}),
    };
    /* One key per intended order, reused on every retry of it. */
    if (!idemKey.current) idemKey.current = newIdempotencyKey();
    setCreating(true);
    let r = await api.order(body, idemKey.current);
    for (let tries = 0; !r.ok && tries < 3; tries++) {
      /* Unknown outcome (network failure, timeout, 502/504) or still opening: retry
         the SAME request with the SAME key. A 503 (proxy_not_configured, Float
         unavailable) will not fix itself in seconds; 429 is retried once its
         Retry-After has passed, if that is short. */
      const unknown = r.err.code === "idempotency_in_progress" || r.status === 0 || r.status === 502 || r.status === 504;
      const limited = r.status === 429 && (r.retryAfter ?? 60) <= 10;
      if (!unknown && !limited) break;
      const waitMs = Math.min(r.retryAfter ?? 2, 10) * 1000;
      await new Promise((res) => setTimeout(res, waitMs));
      r = await api.order(body, idemKey.current);
    }
    setCreating(false);
    if (r.ok) {
      setOrder(r.data);
      setOrderInUrl(r.data.claimToken);
      return;
    }
    if (r.err.code === "venue_unavailable") {
      /* Nothing was opened. Grey that route out and move to the next one. */
      const refused = (r.err.venue ?? sel.venue).toLowerCase();
      const nextDown = [...new Set([...down, refused])];
      setDown(nextDown);
      setDownNote(r.err.error);
      const next = routes.findIndex((x) => !nextDown.includes(x.venue.toLowerCase()));
      if (next >= 0) setRouteIdx(next);
      return;
    }
    if (r.status === 429) { setErr(`Too many requests — try again in ${r.retryAfter ?? 60} s.`); return; }
    setErr(r.err.error);
  }, [q, sel, asset, dest, inbound, refundParty, tag, tagForOrder, tagPending, dir, amount, rateType, refundAddress, down, routes]);

  if (restoring) return <div className="fw-card"><p className="fw-muted" role="status">Loading your order…</p></div>;
  if (order) {
    return (
      <OrderView
        order={order}
        onOrder={setOrder}
        onNew={() => {
          /* A new intended order: a new Idempotency-Key, and a fresh quote (the old one is stale). */
          idemKey.current = null;
          setOrder(null);
          setOrderInUrl(null);
          setErr(null);
          setQuoteNonce((n) => n + 1);
        }}
      />
    );
  }

  const minIn = q?.minIn ? showAmount(q.minIn, decimals(from?.symbol ?? ""), "up") : null;

  return (
    <div className="fw-card">
      <div className="fw-tabs" role="tablist" aria-label="Direction">
        <button type="button" role="tab" aria-selected={inbound} className={"fw-tab" + (inbound ? " on" : "")} onClick={() => flip("in")}>Get CC</button>
        <button type="button" role="tab" aria-selected={!inbound} className={"fw-tab" + (!inbound ? " on" : "")} onClick={() => flip("out")}>Withdraw CC</button>
      </div>

      <div className="fw-leg">
        <div className="fw-leg-head"><span className="fw-label">You deposit</span><span className="fw-muted">{from?.networkName ? `on ${from.networkName}` : ""}</span></div>
        <div className="fw-leg-row">
          {inbound ? (
            <button type="button" className="fw-token-btn" onClick={() => setPickerOpen(true)} aria-haspopup="dialog" aria-label={`Choose token to send, currently ${asset?.symbol ?? "none"}`}>
              {asset ? <TokenFace asset={asset} /> : null}<span className="fw-sym">{asset?.symbol ?? "Choose"}</span><span aria-hidden>▾</span>
            </button>
          ) : (
            <span className="fw-token-btn static"><TokenFace asset={CC_FACE} /><span className="fw-sym">CC</span></span>
          )}
          <input className="fw-amount" inputMode="decimal" aria-label="Amount" value={amount} onChange={(e) => setAmount(e.target.value.replace(/[^0-9.]/g, ""))} />
        </div>
      </div>

      <div className="fw-leg receive">
        <div className="fw-leg-head"><span className="fw-label">You receive</span><span className="fw-muted">{to?.networkName ? `on ${to.networkName}` : ""}</span></div>
        <div className="fw-leg-row">
          {!inbound ? (
            <button type="button" className="fw-token-btn" onClick={() => setPickerOpen(true)} aria-haspopup="dialog" aria-label={`Choose token to receive, currently ${asset?.symbol ?? "none"}`}>
              {asset ? <TokenFace asset={asset} /> : null}<span className="fw-sym">{asset?.symbol ?? "Choose"}</span><span aria-hidden>▾</span>
            </button>
          ) : (
            <span className="fw-token-btn static"><TokenFace asset={CC_FACE} /><span className="fw-sym">CC</span></span>
          )}
          <span className="fw-amount" aria-live="polite">{q && sel ? showAmount(sel.amountOut, decimals(to?.symbol ?? "")) : quoting ? "…" : "—"}</span>
        </div>
      </div>

      <div className="fw-field">
        <span className="fw-label">Rate</span>
        <div className="fw-seg" role="radiogroup" aria-label="Rate type">
          <button type="button" role="radio" aria-checked={rateType === "float"} className={"fw-seg-btn" + (rateType === "float" ? " on" : "")} onClick={() => setRateType("float")}>Float rate</button>
          <button type="button" role="radio" aria-checked={rateType === "fixed"} className={"fw-seg-btn" + (rateType === "fixed" ? " on" : "")} onClick={() => setRateType("fixed")}>Fixed rate</button>
        </div>
        <span className="fw-hint">{rateType === "float" ? "Follows the market until your deposit confirms. Usually reaches more venues." : "Locked for the whole order. Same fee either way."}</span>
      </div>

      {quoting ? <p className="fw-muted" role="status">Asking every partner for a quote…</p> : null}
      {quoteErr ? <p className="fw-alert" role="alert">{quoteErr}</p> : null}
      {q?.warning ? <p className="fw-warn">{q.warning}</p> : null}

      {q && routes.length ? (
        <fieldset className="fw-routes">
          <legend className="fw-label">Choose your route</legend>
          {routes.map((r, i) => {
            const isDown = down.includes(r.venue);
            return (
              <label key={r.venue} className={"fw-route" + (i === routeIdx ? " on" : "") + (isDown ? " down" : "")}>
                <input type="radio" name="fw-route" checked={i === routeIdx} disabled={isDown} onChange={() => setRouteIdx(i)} />
                <span className="fw-route-main">
                  <span className="fw-route-name">{venueName(r.venue)}</span>
                  {i === 0 ? <span className="fw-tag ok">BEST RATE</span> : null}
                  {r.needsRefundAddress ? <span className="fw-tag warn">NEEDS REFUND ADDRESS</span> : null}
                  {isDown ? <span className="fw-tag bad">UNAVAILABLE</span> : null}
                  {r.note ? <span className="fw-muted fw-small">{r.note}</span> : null}
                </span>
                <span className="fw-mono">{showAmount(r.amountOut, decimals(to?.symbol ?? ""))} {to?.symbol}</span>
              </label>
            );
          })}
          {rateType === "fixed" ? (
            <p className="fw-hint">Fewer partners offer a fixed rate. Floating rate compares more routes. <button type="button" className="fw-link" onClick={() => setRateType("float")}>Compare floating</button></p>
          ) : null}
          {downNote ? <p className="fw-alert" role="alert">{downNote}</p> : null}
          <div className="fw-summary">
            <span>Fee</span><span className="fw-mono">{feeText(sel?.totalFeeBps)}</span>
            {minIn ? (<><span>Minimum</span><span className="fw-mono">{minIn} {from?.symbol}</span></>) : null}
          </div>
          {q.unavailable.length ? (
            <details className="fw-small fw-muted">
              <summary>Could not quote ({q.unavailable.length})</summary>
              <ul>{q.unavailable.map((u) => <li key={u.venue}>{venueName(u.venue)} — {u.reason}</li>)}</ul>
            </details>
          ) : null}
        </fieldset>
      ) : null}

      <div className="fw-field">
        <label className="fw-label" htmlFor="fw-dest">{inbound ? "Your Canton party ID" : `Your ${asset?.symbol ?? ""} address`}</label>
        <input id="fw-dest" className="fw-input mono" value={dest} onChange={(e) => setDest(e.target.value)} autoComplete="off" spellCheck={false}
          placeholder={inbound ? "yourname::1220e8c7b41d9a2f5c6b8e3d07f1a9c4b2d6e8f0" : `Paste your ${asset?.symbol ?? ""} address${asset?.networkName ? ` on ${asset.networkName}` : ""}`} />
        <span className="fw-hint">{inbound ? "CC settles straight to this party ID on Canton." : "Double-check the network. Payouts cannot be reversed."}</span>
      </div>

      {!inbound ? (
        <>
          {showTag ? (
            <div className="fw-field">
              <label className="fw-label" htmlFor="fw-tag">{tagName}</label>
              <input id="fw-tag" className="fw-input mono" value={tag} onChange={(e) => setTag(e.target.value.replace(/\s+/g, ""))} autoComplete="off" spellCheck={false}
                inputMode={tagName === "Destination tag" ? "numeric" : "text"} maxLength={64} placeholder={tagName === "Destination tag" ? "e.g. 104729" : "e.g. 1234567890"} />
              <span className="fw-hint">Optional — only if your address needs one.</span>
              <p className="fw-warn" role="note">Sending to an exchange? Add your destination tag/memo, or the funds may be lost.</p>
            </div>
          ) : null}
          <div className="fw-field">
            <label className="fw-label" htmlFor="fw-refund-party">Your Canton party for refunds (optional)</label>
            <input id="fw-refund-party" className="fw-input mono" value={refundParty} onChange={(e) => setRefundParty(e.target.value)} autoComplete="off" spellCheck={false} placeholder="yourname::1220e8c7b41d9a2f5c6b8e3d07f1a9c4b2d6e8f0" />
            <span className="fw-hint">If the swap can&apos;t complete, your CC comes back here. Leave empty to refund the wallet you send from — but if you send from an exchange, fill this in.</span>
          </div>
        </>
      ) : sel?.needsRefundAddress ? (
        <div className="fw-field">
          <label className="fw-label" htmlFor="fw-refund">Your {asset?.symbol} refund address</label>
          <input id="fw-refund" className="fw-input mono" value={refundAddress} onChange={(e) => setRefundAddress(e.target.value)} autoComplete="off" spellCheck={false} placeholder={`Your ${asset?.symbol ?? ""} address${asset?.networkName ? ` on ${asset.networkName}` : ""}`} />
          <span className="fw-hint">{venueName(sel.venue)} returns your deposit here if the swap cannot complete.</span>
        </div>
      ) : null}

      {err ? <p className="fw-alert" role="alert">{err}</p> : null}

      <button type="button" className="fw-primary" disabled={!q || !sel || selDown || creating || tagPending} onClick={() => void exchange()}>
        {creating ? "Opening the order…" : !asset ? "Choose a token" : quoting || (q && tagPending) ? "Asking every venue…" : !q ? (Number(amount) > 0 ? "No route at this amount" : "Enter an amount") : selDown ? "Pick another route" : "Exchange"}
      </button>
      <p className="fw-hint center">Creating an order does not move funds. You send the deposit yourself.</p>

      <TokenPicker open={pickerOpen} onClose={() => setPickerOpen(false)} title={inbound ? "Choose the token you pay with" : "Choose the token you receive"}
        onPick={pick} />
    </div>
  );
}

/* ── The order: deposit step and status ───────────────────────────── */

function OrderView({ order, onOrder, onNew }: { order: Order; onOrder: (o: Order) => void; onNew: () => void }) {
  const inbound = order.direction === "in";
  const copy = stateCopy(order);
  const step = stepOf(order);
  const labels = stepLabels(order);
  const win = windowOf(order);
  const sendAsset = order.from;
  const showDeposit = order.state === "awaiting_deposit" && !!order.depositAddress && win?.phase !== "closed" && win?.phase !== "grace";
  const final = FINAL_STATES.includes(order.state);
  const link = typeof window !== "undefined" ? (() => { const u = new URL(window.location.href); u.searchParams.set("order", order.claimToken); return u.toString(); })() : "";

  return (
    <div className="fw-card">
      <ol className="fw-steps" aria-label="Progress">
        {labels.map((l, i) => (
          <li key={l} className={i < step ? "done" : i === step ? "now" : ""} aria-current={i === step ? "step" : undefined}>{l}</li>
        ))}
      </ol>

      {order.state === "awaiting_refund_address" ? <RefundAddressForm order={order} onOrder={onOrder} /> : null}

      {showDeposit ? (
        <div className="fw-deposit">
          <span className="fw-label">Send</span>
          <div className="fw-big fw-mono">{order.amountIn} {sendAsset}{inbound && order.network && order.network !== sendAsset ? <span className="fw-muted fw-small"> on {order.network}</span> : null}</div>
          <span className="fw-hint">
            {inbound
              ? "Send exactly this amount, on this network."
              : "Send exactly this amount of CC from your Canton wallet. Less is refunded and the order cancelled; more is processed and the extra refunded."}
          </span>

          {win && win.phase === "open" ? (
            <div className={"fw-timer" + (win.secondsLeft < URGENT_SECONDS ? " urgent" : "")} role="timer">
              <span>Send within <strong className="fw-mono">{countdown(win.secondsLeft)}</strong></span>
              <span className="fw-small">by {new Date(win.deadline).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} your time</span>
              {win.secondsLeft < URGENT_SECONDS ? <span className="fw-small">Less than 5 minutes left — only send if your transfer can go out now.</span> : null}
            </div>
          ) : null}

          <span className="fw-label">To this deposit address</span>
          <div className="fw-address">
            <Qr value={order.depositAddress} />
            <div className="fw-address-main">
              <code className="fw-mono fw-break">{order.depositAddress}</code>
              <Copy text={order.depositAddress} />
              {order.depositValidForThisOrderOnly ? <p className="fw-note"><strong>{order.depositAddressNote ?? "Valid for this order only"}.</strong> Do not reuse it for another order.</p> : null}
              {!inbound ? <p className="fw-hint">No memo needed.</p> : null}
            </div>
          </div>

          {inbound && order.depositExtraId ? (
            <div className="fw-memo">
              <span className="fw-label">Memo — required</span>
              <code className="fw-mono fw-break">{order.depositExtraId}</code> <Copy text={order.depositExtraId} />
              <p className="fw-small">Include this with your transfer. Without it the exchange cannot tell your deposit from anyone else&rsquo;s.</p>
            </div>
          ) : null}

          {!inbound && !order.refundParty ? <RefundPartyForm order={order} onOrder={onOrder} /> : null}
        </div>
      ) : null}

      <div className={"fw-status " + copy.tone} role="status" aria-live="polite">
        <strong>{copy.title}</strong>
        <p>{copy.body}</p>
      </div>

      <dl className="fw-summary">
        <dt>You send</dt><dd className="fw-mono">{order.amountIn} {order.from}</dd>
        <dt>You receive</dt><dd className="fw-mono">{showAmount(order.expectedOut, decimals(order.to))} {order.to}{!inbound && order.network && order.network !== order.to ? ` on ${order.network}` : ""}</dd>
        <dt>To</dt><dd className="fw-mono fw-break">{order.deliversTo}</dd>
        {order.destinationExtraId ? (<><dt>{tagNameOf(order)}</dt><dd className="fw-mono fw-break">{order.destinationExtraId}</dd></>) : null}
        <dt>Route</dt><dd>{venueName(order.venue)}</dd>
      </dl>

      <div className="fw-field">
        <span className="fw-label">Order link</span>
        <div className="fw-row-inline"><code className="fw-mono fw-break fw-small">{link}</code><Copy text={link} /></div>
        <span className="fw-hint">It reopens this order any time, even after you close this page.</span>
      </div>

      {order.transactions?.length ? (
        <div className="fw-field">
          <span className="fw-label">Transactions</span>
          <ul className="fw-txs">
            {order.transactions.map((t, i) => (
              <li key={i}>
                <span>{t.kind.replace(/_/g, " ")}</span>
                <span className="fw-mono">{t.amount ? `${t.amount} ${t.asset}` : t.asset}</span>
                <span className={"fw-tag " + (t.state === "confirmed" ? "ok" : t.state === "pending" ? "" : "bad")}>{t.state}</span>
                {t.explorerUrl ? <a href={t.explorerUrl} target="_blank" rel="noreferrer">View</a> : null}
              </li>
            ))}
          </ul>
        </div>
      ) : null}

      {final ? (
        <div className="fw-row-inline">
          <button type="button" className="fw-primary" onClick={onNew}>New order</button>
          <a className="fw-secondary" href="mailto:support@myfloat.cc">Contact support</a>
        </div>
      ) : null}
    </div>
  );
}

function RefundAddressForm({ order, onOrder }: { order: Order; onOrder: (o: Order) => void }) {
  const [address, setAddress] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  const [requote, setRequote] = useState<string | null>(null);
  const submit = async (acceptOut?: string) => {
    setBusy(true); setErr(null);
    const r = await api.refundAddress(order.claimToken, { address: address.trim(), ...(acceptOut ? { acceptOut } : {}) });
    setBusy(false);
    if (r.ok) { setRequote(null); onOrder(r.data.order); return; }
    if (r.err.code === "requote_needed" && r.err.requote) { setRequote(r.err.requote.freshOut); return; }
    setErr(r.err.error);
  };
  return (
    <div className="fw-field">
      <label className="fw-label" htmlFor="fw-ra">Your {order.from} refund address</label>
      <input id="fw-ra" className="fw-input mono" value={address} onChange={(e) => setAddress(e.target.value)} autoComplete="off" spellCheck={false} />
      {requote ? (
        <p className="fw-warn">The rate moved: you would now receive {showAmount(requote, decimals(order.to))} {order.to}. <button type="button" className="fw-link" onClick={() => void submit(requote)}>Accept and open the order</button></p>
      ) : null}
      {err ? <p className="fw-alert" role="alert">{err}</p> : null}
      <button type="button" className="fw-primary" disabled={!address.trim() || busy} onClick={() => void submit()}>{busy ? "Saving…" : "Save refund address"}</button>
    </div>
  );
}

function RefundPartyForm({ order, onOrder }: { order: Order; onOrder: (o: Order) => void }) {
  const [open, setOpen] = useState(false);
  const [party, setParty] = useState("");
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState<string | null>(null);
  if (!open) return <button type="button" className="fw-link" onClick={() => setOpen(true)}>Paying from an exchange? Add a Canton party for refunds</button>;
  return (
    <div className="fw-field">
      <label className="fw-label" htmlFor="fw-rp">Your Canton party for refunds</label>
      <input id="fw-rp" className="fw-input mono" value={party} onChange={(e) => setParty(e.target.value)} autoComplete="off" spellCheck={false} placeholder="yourname::1220…" />
      {msg ? <p className="fw-hint" role="status">{msg}</p> : null}
      <button type="button" className="fw-secondary" disabled={busy || !looksLikeCantonParty(party)} onClick={async () => {
        setBusy(true);
        const r = await api.refundAddress(order.claimToken, { address: party.trim() });
        setBusy(false);
        if (r.ok) { setMsg(r.data.message); onOrder(r.data.order); } else setMsg(r.err.error);
      }}>{busy ? "Saving…" : "Save refund party"}</button>
    </div>
  );
}
```

### `components/TokenPicker.tsx` — the token picker

```tsx
"use client";

/**
 * Token picker built on GET /v1/assets?all=1 (through this app's proxy).
 *
 * - popular tokens first, then the rest A–Z;
 * - search by ticker or name (exact ticker, then prefix, then anything);
 * - network chips with Float-hosted chain logos;
 * - each token's Float-hosted logo with its chain as a corner badge, and a
 *   lettered tile whenever an image is null or fails to load;
 * - keyboard: focus starts in search; ↑/↓ move and Enter picks (from the search
 *   box only, so Enter on a network chip or × does what that button says);
 *   Tab stays inside the dialog; Esc closes; focus returns to the opener.
 *
 * It returns the row; quote with `from`/`to` = row.symbol and
 * `network` = row.network (when the row has one).
 */

import { useEffect, useMemo, useRef, useState } from "react";
import { api, type Asset } from "@/lib/float";

export const POPULAR = ["USDT", "USDC", "BTC", "ETH", "SOL", "BNB", "XRP", "TRX", "DOGE", "LTC", "TON", "AVAX"];
const FEATURED_NETWORKS = ["ETHEREUM", "SOLANA", "TRON", "BSC", "BITCOIN", "ARBITRUM", "BASE", "POLYGON", "AVALANCHE", "OPTIMISM", "TON"];
const MAX_ROWS = 300;

let catalog: Promise<Asset[]> | null = null;
/** Every asset, fetched once per page load (Float caches it for 10 minutes). */
export function loadAssets(): Promise<Asset[]> {
  if (!catalog) {
    catalog = api.allAssets().then((r) => {
      if (!r.ok) { catalog = null; return []; }
      /* CC is the other side of every swap, never a choice here. */
      return r.data.assets.filter((a) => a.symbol !== "CC");
    });
  }
  return catalog;
}

function Tile({ text, size }: { text: string; size: number }) {
  return (
    <span className="fw-tile" style={{ width: size, height: size, fontSize: size * 0.42 }} aria-hidden>
      {text.replace(/[^A-Za-z0-9]/g, "").slice(0, 1).toUpperCase() || "?"}
    </span>
  );
}

/** An image with a lettered-tile fallback (null URL, or a load error). */
export function Img({ src, label, size }: { src: string | null; label: string; size: number }) {
  const [bad, setBad] = useState(false);
  useEffect(() => setBad(false), [src]);
  if (!src || bad) return <Tile text={label} size={size} />;
  // eslint-disable-next-line @next/next/no-img-element
  return <img src={src} alt="" width={size} height={size} className="fw-img" onError={() => setBad(true)} loading="lazy" />;
}

/** Token logo with its chain as a badge in the corner. */
export function TokenFace({ asset, size = 32 }: { asset: Pick<Asset, "symbol" | "icon" | "networkIcon" | "networkName">; size?: number }) {
  return (
    <span className="fw-face" style={{ width: size, height: size }}>
      <Img src={asset.icon} label={asset.symbol} size={size} />
      {asset.networkIcon || asset.networkName ? (
        <span className="fw-badge" title={asset.networkName ?? undefined}>
          <Img src={asset.networkIcon} label={asset.networkName ?? ""} size={Math.round(size * 0.45)} />
        </span>
      ) : null}
    </span>
  );
}

function rank(a: Asset, q: string): number {
  const s = a.symbol.toLowerCase();
  if (s === q) return 0;
  if (s.startsWith(q)) return 1;
  return 2;
}

export default function TokenPicker({
  open, onClose, onPick, title,
}: { open: boolean; onClose: () => void; onPick: (a: Asset) => void; title: string }) {
  const [assets, setAssets] = useState<Asset[] | null>(null);
  const [query, setQuery] = useState("");
  const [net, setNet] = useState<string>("all");
  const [active, setActive] = useState(0);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const listRef = useRef<HTMLUListElement | null>(null);
  const dialogRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!open) return;
    /* Whatever opened the picker gets focus back when it closes. */
    const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
    void loadAssets().then(setAssets);
    setQuery("");
    setActive(0);
    const t = setTimeout(() => inputRef.current?.focus(), 0);
    return () => {
      clearTimeout(t);
      if (opener && opener.isConnected) setTimeout(() => opener.focus(), 0);
    };
  }, [open]);

  const networks = useMemo(() => {
    const m = new Map<string, { code: string; name: string; icon: string | null; n: number }>();
    for (const a of assets ?? []) {
      if (!a.network) continue;
      const cur = m.get(a.network) ?? { code: a.network, name: a.networkName ?? a.network, icon: a.networkIcon, n: 0 };
      cur.n += 1;
      m.set(a.network, cur);
    }
    const all = [...m.values()];
    const featured = FEATURED_NETWORKS.map((c) => m.get(c)).filter((x): x is NonNullable<typeof x> => !!x);
    const rest = all.filter((x) => !FEATURED_NETWORKS.includes(x.code)).sort((a, b) => b.n - a.n);
    return [...featured, ...rest];
  }, [assets]);

  const rows = useMemo(() => {
    const q = query.trim().toLowerCase();
    let list = (assets ?? []).filter((a) => net === "all" || a.network === net);
    if (q) {
      list = list
        .filter((a) => a.symbol.toLowerCase().includes(q) || (a.name ?? "").toLowerCase().includes(q))
        .sort((a, b) => rank(a, q) - rank(b, q) || a.symbol.localeCompare(b.symbol));
    } else {
      const pop = (a: Asset) => {
        const i = POPULAR.indexOf(a.symbol.toUpperCase());
        return i === -1 ? POPULAR.length : i;
      };
      list = [...list].sort((a, b) => pop(a) - pop(b) || a.symbol.localeCompare(b.symbol));
    }
    return list.slice(0, MAX_ROWS);
  }, [assets, query, net]);

  useEffect(() => setActive(0), [query, net]);
  useEffect(() => {
    listRef.current?.querySelector<HTMLElement>(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" });
  }, [active]);

  if (!open) return null;

  /* Dialog-wide: Esc closes, and Tab cycles inside the dialog (a focus trap). */
  const onDialogKey = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
    if (e.key !== "Tab" || !dialogRef.current) return;
    const items = [...dialogRef.current.querySelectorAll<HTMLElement>("button, input, [tabindex]:not([tabindex='-1'])")].filter((el) => !el.hasAttribute("disabled"));
    if (!items.length) return;
    const first = items[0]!;
    const last = items[items.length - 1]!;
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  };
  /* The list keys belong to the search box only (it owns aria-activedescendant). */
  const onSearchKey = (e: React.KeyboardEvent) => {
    if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => Math.min(rows.length - 1, i + 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(0, i - 1)); }
    else if (e.key === "Enter" && rows[active]) { e.preventDefault(); onPick(rows[active]!); }
  };

  return (
    <div className="fw-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div ref={dialogRef} className="fw-dialog" role="dialog" aria-modal="true" aria-label={title} onKeyDown={onDialogKey}>
        <div className="fw-dialog-head">
          <strong>{title}</strong>
          <button type="button" className="fw-icon-btn" onClick={onClose} aria-label="Close">×</button>
        </div>
        <input
          ref={inputRef}
          className="fw-input"
          placeholder="Search by name or ticker"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={onSearchKey}
          aria-label="Search tokens"
          aria-controls="fw-token-list"
          aria-activedescendant={rows[active] ? `fw-tok-${active}` : undefined}
        />
        <div className="fw-chips" role="toolbar" aria-label="Filter by network">
          <button type="button" className={"fw-chip" + (net === "all" ? " on" : "")} onClick={() => setNet("all")} aria-pressed={net === "all"}>All networks</button>
          {networks.map((n) => (
            <button key={n.code} type="button" className={"fw-chip" + (net === n.code ? " on" : "")} onClick={() => setNet(n.code)} aria-pressed={net === n.code}>
              <Img src={n.icon} label={n.name} size={16} />
              {n.name}
            </button>
          ))}
        </div>
        <ul id="fw-token-list" ref={listRef} className="fw-list" role="listbox" aria-label="Tokens">
          {assets === null ? <li className="fw-muted fw-pad">Loading tokens…</li> : null}
          {assets !== null && !rows.length ? <li className="fw-muted fw-pad">No token matches “{query}”.</li> : null}
          {rows.map((a, i) => (
            <li
              key={`${a.symbol}:${a.network ?? ""}`}
              id={`fw-tok-${i}`}
              data-i={i}
              role="option"
              aria-selected={i === active}
              className={"fw-row" + (i === active ? " active" : "")}
              onMouseEnter={() => setActive(i)}
              onClick={() => onPick(a)}
            >
              <TokenFace asset={a} />
              <span className="fw-row-main">
                <span className="fw-sym">{a.symbol}</span>
                <span className="fw-muted">{a.name ?? a.symbol}</span>
              </span>
              <span className="fw-muted fw-row-net">{a.networkName ?? ""}</span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}
```

### `lib/float.ts` — client, types, amounts and copy

```ts
/**
 * Browser-side helpers for the Float widget. Every call goes to this app's
 * own proxy (/api/float/…), never to Float directly: the API key lives on
 * the server, and Float's API is not callable cross-origin from your domain.
 *
 * Types mirror https://www.myfloat.cc/openapi.json.
 */

export type Direction = "in" | "out";
export type RateType = "float" | "fixed";

export type Asset = {
  symbol: string;
  name?: string;
  network?: string;
  networkName: string | null;
  icon: string | null;
  networkIcon: string | null;
  /** Withdraw CC: payouts of this asset take a destination tag / memo (absent otherwise). */
  destinationExtraIdNeeded?: true;
  destinationExtraIdName?: string;
};

export type Alternative = {
  venue: string;
  amountOut: string;
  network: string | null;
  note: string | null;
  totalFeeBps: number | null;
  needsRefundAddress: boolean;
  destinationExtraIdName?: string | null;
  destinationExtraIdNeeded?: boolean;
};

export type Quote = {
  direction: Direction;
  from: string;
  to: string;
  amountIn: string;
  amountOut: string;
  venue: string;
  network: string | null;
  networkChoice: "requested" | "float_default" | "venue_default";
  totalFeeBps: number | null;
  warning: string | null;
  needsRefundAddress: boolean;
  /** Withdraw CC: what this route calls the payout tag / memo (null: it cannot pass one). */
  destinationExtraIdName: string | null;
  destinationExtraIdNeeded: boolean;
  alternatives: Alternative[];
  unavailable: { venue: string; reason: string; minIn?: string }[];
  rateType: RateType;
  minIn: string | null;
  maxIn: string | null;
};

export type OrderState =
  | "awaiting_refund_address" | "preparing_deposit_address" | "awaiting_deposit" | "confirming"
  | "exchanging" | "sending" | "completed" | "refunded" | "failed" | "expired";

export type Transaction = {
  kind: "customer_deposit" | "venue_payment" | "venue_payout" | "customer_payout" | "refund";
  chain: string | null;
  asset: string;
  amount: string | null;
  txId: string | null;
  explorerUrl: string | null;
  state: "pending" | "confirmed" | "failed" | "cancelled";
  at: string | null;
};

export type Order = {
  claimToken: string;
  direction: Direction;
  from: string;
  to: string;
  amountIn: string;
  expectedOut: string;
  venue: string;
  depositAddress: string;
  depositExtraId: string | null;
  deliversTo: string;
  /** Withdraw CC: the payout's destination tag / memo as the partner was given it, or null. */
  destinationExtraId?: string | null;
  state: OrderState;
  network: string | null;
  receivedCc: string | null;
  createdAt: string;
  depositDeadline: string | null;
  depositGraceUntil: string | null;
  transactions?: Transaction[];
  refundAddress?: string | null;
  refundAddressRequired?: boolean;
  refundParty?: string | null;
  depositValidForThisOrderOnly?: boolean;
  depositAddressNote?: string;
  depositPreparing?: boolean;
};

export type ApiError = { error: string; code: string; venue?: string; requote?: { venue: string; previousOut: string; freshOut: string } };
export type Result<T> = { ok: true; data: T; status: number } | { ok: false; err: ApiError; status: number; retryAfter: number | null };

export const FINAL_STATES: OrderState[] = ["completed", "refunded", "failed", "expired"];

async function call<T>(path: string, init?: RequestInit): Promise<Result<T>> {
  try {
    const res = await fetch(`/api/float/${path}`, init);
    const json = (await res.json().catch(() => null)) as T | ApiError | null;
    if (res.ok && json) return { ok: true, data: json as T, status: res.status };
    /* A non-JSON error (a gateway page, a crash) says nothing about what happened upstream:
       on POST /order the order may or may not have opened — retry with the same Idempotency-Key. */
    const e = json && typeof json === "object" && "error" in json && typeof (json as ApiError).error === "string"
      ? (json as ApiError)
      : { error: `Something went wrong (HTTP ${res.status}). Try again.`, code: "upstream_error" };
    const ra = Number(res.headers.get("retry-after"));
    return { ok: false, err: e, status: res.status, retryAfter: Number.isFinite(ra) && ra > 0 ? ra : null };
  } catch {
    return { ok: false, err: { error: "Could not reach the server. Check your connection and try again.", code: "network" }, status: 0, retryAfter: null };
  }
}

const post = (body: unknown, headers: Record<string, string> = {}): RequestInit => ({
  method: "POST",
  headers: { "content-type": "application/json", ...headers },
  body: JSON.stringify(body),
});

export const api = {
  allAssets: () => call<{ total: number; assets: Asset[] }>("assets?all=1"),
  quote: (b: { direction: Direction; from?: string; to?: string; amount: string; rateType: RateType; network?: string; destinationExtraId?: string; timezone?: string }, signal?: AbortSignal) =>
    call<Quote>("quote", { ...post(b), signal }),
  order: (b: Record<string, unknown>, idempotencyKey: string) => call<Order>("order", post(b, { "idempotency-key": idempotencyKey })),
  get: (token: string) => call<Order>(`order/${encodeURIComponent(token)}`, { cache: "no-store" }),
  refundAddress: (token: string, b: { address: string; acceptOut?: string }) => call<{ opened: boolean; message: string; order: Order }>(`order/${encodeURIComponent(token)}/refund-address`, post(b)),
};

/* ── Amounts are decimal strings ──────────────────────────────────── *
 * Float returns amounts as strings, and venue figures can carry binary-float
 * artifacts ("953.672096269999997276"). Never do money arithmetic on them
 * with floats; for DISPLAY, cut to a sensible number of places:
 *   - what the user must SEND (amountIn): show exactly as returned;
 *   - what they RECEIVE (amountOut, expectedOut): round DOWN;
 *   - a minimum (minIn): round UP; a maximum (maxIn): round DOWN.        */
export function decimals(asset: string): number {
  const a = asset.toUpperCase();
  if (["USDT", "USDC", "DAI", "USDE", "PYUSD", "USD1", "FDUSD", "TUSD"].some((s) => a === s || a.startsWith(s + "-"))) return 2;
  if (a === "CC") return 4;
  if (["BTC", "WBTC", "CBTC"].includes(a)) return 8;
  return 6;
}

/** Decimal string → display string, cut (not rounded) to `dp` places, or rounded up. */
export function showAmount(v: string | null | undefined, dp: number, mode: "down" | "up" = "down"): string {
  if (!v) return "—";
  const m = /^(-?)(\d+)(?:\.(\d+))?$/.exec(v.trim());
  if (!m) return v;
  const [, sign, int, frac = ""] = m;
  let i = BigInt(int!);
  let f = frac.slice(0, dp).padEnd(dp, "0");
  if (mode === "up" && /[1-9]/.test(frac.slice(dp))) {
    const scaled = BigInt(int! + f) + 1n;
    const s = scaled.toString().padStart(dp + 1, "0");
    i = BigInt(s.slice(0, s.length - dp) || "0");
    f = s.slice(s.length - dp);
  }
  const intStr = i.toLocaleString("en-US");
  const fracTrim = f.replace(/0+$/, "");
  return sign + intStr + (fracTrim ? "." + fracTrim : "");
}

export const VENUE_LABEL: Record<string, string> = {
  letsexchange: "YNOT",
  thechange: "The Change",
  exolix: "Exolix",
  houdiniswap: "HoudiniSwap",
  rocketx: "RocketX",
  squid: "Squid",
  stealthex: "StealthEX",
};
export const venueName = (v: string) => VENUE_LABEL[v.toLowerCase()] ?? v;

export function looksLikeCantonParty(v: string): boolean {
  const t = v.trim();
  const i = t.indexOf("::");
  return i > 0 && /^[0-9a-f]{16,}$/i.test(t.slice(i + 2));
}

export function newIdempotencyKey(): string {
  return typeof crypto !== "undefined" && "randomUUID" in crypto
    ? crypto.randomUUID()
    : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}

/* ── The deposit window ───────────────────────────────────────────── */
export type WindowPhase = "open" | "grace" | "closed";
export function windowOf(o: Order, now = Date.now()): { phase: WindowPhase; secondsLeft: number; deadline: number } | null {
  if (!o.depositDeadline) return null;
  const deadline = Date.parse(o.depositDeadline);
  const grace = o.depositGraceUntil ? Date.parse(o.depositGraceUntil) : null;
  const phase: WindowPhase = now < deadline ? "open" : grace !== null && now < grace ? "grace" : "closed";
  return { phase, secondsLeft: Math.max(0, Math.floor((deadline - now) / 1000)), deadline };
}
export function countdown(s: number): string {
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const ss = String(s % 60).padStart(2, "0");
  return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${String(m).padStart(2, "0")}:${ss}`;
}

/* ── What to tell the user, per state (Float's own copy) ──────────── */
export function stateCopy(o: Order, now = Date.now()): { title: string; body: string; tone: "you" | "wait" | "ok" | "bad" } {
  const inbound = o.direction === "in";
  const sendAsset = inbound ? o.from : "CC";
  const backTo = !inbound && o.refundParty ? "your refund party" : "the address it came from";
  switch (o.state) {
    case "awaiting_refund_address":
      return { title: "Add a refund address", body: "This partner needs a refund address before it gives you a deposit address. Add yours below — nothing is sent or charged until you deposit.", tone: "you" };
    case "preparing_deposit_address":
      return { title: "Preparing your deposit address…", body: "We are creating a deposit address for this order alone. It appears here in a moment — there is nothing to send yet.", tone: "wait" };
    case "awaiting_deposit": {
      const w = windowOf(o, now);
      if (w && w.phase !== "open") {
        return {
          title: "Deposit window closed",
          body: "If you already sent, it will still be processed or refunded automatically. Don't send now." + (w.phase === "grace" ? " A payment already on its way is still accepted for a few more minutes." : ""),
          tone: "bad",
        };
      }
      return { title: `Waiting for your ${sendAsset}`, body: "Send the amount shown to the address above. We detect it automatically, and nothing is charged until you do.", tone: "you" };
    }
    case "confirming":
      return inbound
        ? { title: "Deposit received", body: "The exchange has seen your deposit and is confirming it.", tone: "wait" }
        : { title: "Got your CC", body: "Your deposit has arrived with us. We are paying the venue now — you do not need to do anything.", tone: "wait" };
    case "exchanging":
      return inbound
        ? { title: "Exchange in progress", body: "The exchange has your deposit and is converting it. Your CC lands as soon as it clears.", tone: "wait" }
        : { title: "Swapping now", body: `The exchange is converting your CC to ${o.to} and will send it straight to your address.`, tone: "wait" };
    case "sending":
      return inbound
        ? { title: "Sending your CC", body: "Your CC is on its way to your Canton party.", tone: "wait" }
        : { title: `${o.to} on its way`, body: `The exchange has your CC and is sending ${o.to} to your address.`, tone: "wait" };
    case "completed":
      return { title: "Done", body: `${showAmount(o.expectedOut, decimals(o.to))} ${o.to} sent to your address.`, tone: "ok" };
    case "refunded":
      return { title: "Refunded", body: `Your deposit has been returned to ${backTo}.`, tone: "ok" };
    case "expired":
      return { title: "Expired", body: "Nothing was received. Nothing was sent and nothing is owed — start a new order.", tone: "bad" };
    case "failed":
      return { title: "Held up", body: "Something went wrong after your deposit. Float can see this order and is on it — contact support@myfloat.cc with your order link if you want an update.", tone: "bad" };
  }
}

/** The progress rail: which step an order is on. */
export function stepOf(o: Order): number {
  switch (o.state) {
    case "awaiting_refund_address": return 0;
    case "preparing_deposit_address":
    case "awaiting_deposit":
    case "expired": return 1;
    case "confirming":
    case "exchanging":
    case "failed": return 2;
    case "sending": return 3;
    case "completed":
    case "refunded": return 4;
  }
}
export const stepLabels = (o: Order) =>
  o.direction === "in"
    ? ["Order created", `Your ${o.from}`, "Exchanged", "CC sent to you", "Done"]
    : ["Order created", "Your CC", "Sent to the venue", `${o.to} on its way`, "Done"];
```

### `app/globals.css` — brand variables and styles

```css
/* ── Brand: change these to match your product ─────────────────────── */
:root {
  --fw-brand: #1e55f0;          /* primary buttons, selected states */
  --fw-brand-2: #1fd3ee;        /* gradient end; set equal to --fw-brand for a flat button */
  --fw-on-brand: #ffffff;
  --fw-bg: #f4f8fd;             /* page */
  --fw-card: #ffffff;           /* widget surface */
  --fw-surface: #f7fafd;        /* inputs, legs */
  --fw-text: #0e1a33;
  --fw-muted: #5c6e8c;
  --fw-border: #e4ecf6;
  --fw-ok: #1e8a5b;
  --fw-warn: #b7791f;
  --fw-bad: #b0413e;
  --fw-radius: 16px;
  --fw-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --fw-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}

* { box-sizing: border-box; }
body { margin: 0; background: var(--fw-bg); color: var(--fw-text); font-family: var(--fw-font); }
.fw-page { max-width: 560px; margin: 0 auto; padding: 24px 16px 48px; }
.fw-title { font-size: 28px; margin: 0 0 16px; }
.fw-powered { text-align: center; color: var(--fw-muted); font-size: 13px; }
.fw-powered a { color: inherit; }

.fw-card { background: var(--fw-card); border: 1px solid var(--fw-border); border-radius: calc(var(--fw-radius) + 6px); padding: 20px; display: flex; flex-direction: column; gap: 16px; box-shadow: 0 18px 44px rgba(20, 45, 90, 0.07); }
.fw-tabs { display: flex; gap: 4px; background: var(--fw-surface); border-radius: 13px; padding: 4px; }
.fw-tab { flex: 1; border: 0; background: transparent; padding: 11px; border-radius: 10px; font: 600 15px var(--fw-font); color: var(--fw-muted); cursor: pointer; }
.fw-tab.on { background: var(--fw-card); color: var(--fw-text); box-shadow: 0 2px 8px rgba(20, 45, 90, 0.1); }
.fw-leg { background: var(--fw-surface); border: 1px solid var(--fw-border); border-radius: var(--fw-radius); padding: 14px; }
.fw-leg.receive { background: linear-gradient(180deg, color-mix(in srgb, var(--fw-brand-2) 8%, var(--fw-card)), var(--fw-surface)); }
.fw-leg-head { display: flex; justify-content: space-between; gap: 8px; }
.fw-leg-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; }
.fw-label { font: 600 11px var(--fw-mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--fw-muted); }
.fw-muted { color: var(--fw-muted); font-size: 13px; }
.fw-small { font-size: 12.5px; }
.fw-hint { color: var(--fw-muted); font-size: 12.5px; line-height: 1.5; }
.fw-hint.center { text-align: center; margin: 0; }
.fw-mono { font-family: var(--fw-mono); }
.fw-break { overflow-wrap: anywhere; word-break: break-all; }
.fw-pad { padding: 16px; }
.fw-token-btn { display: inline-flex; align-items: center; gap: 8px; background: var(--fw-card); border: 1px solid var(--fw-border); border-radius: 999px; padding: 5px 12px 5px 5px; cursor: pointer; font: inherit; color: inherit; }
.fw-token-btn.static { cursor: default; }
.fw-sym { font-weight: 600; }
.fw-amount { min-width: 0; width: 55%; text-align: right; font: 26px var(--fw-mono); color: var(--fw-text); background: transparent; border: 0; outline: none; overflow: hidden; text-overflow: ellipsis; }
.fw-field { display: flex; flex-direction: column; gap: 8px; }
.fw-input { width: 100%; font: 14px var(--fw-font); color: var(--fw-text); background: var(--fw-card); border: 1px solid var(--fw-border); border-radius: 12px; padding: 13px 14px; }
.fw-input.mono { font-family: var(--fw-mono); font-size: 13px; }
.fw-seg { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.fw-seg-btn { border: 1.5px solid var(--fw-border); background: var(--fw-card); border-radius: 12px; padding: 11px; font: 600 14px var(--fw-font); color: var(--fw-muted); cursor: pointer; }
.fw-seg-btn.on { border-color: var(--fw-brand); color: var(--fw-brand); }
.fw-routes { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.fw-routes legend { margin-bottom: 8px; }
.fw-route { display: flex; align-items: center; gap: 10px; border: 1.5px solid var(--fw-border); border-radius: 12px; padding: 11px 12px; cursor: pointer; }
.fw-route.on { border-color: var(--fw-brand); }
.fw-route.down { opacity: 0.55; cursor: not-allowed; }
.fw-route-main { flex: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
.fw-route-name { font-weight: 600; }
.fw-tag { font: 600 10px var(--fw-mono); letter-spacing: 0.05em; border-radius: 5px; padding: 3px 6px; background: var(--fw-surface); color: var(--fw-muted); text-transform: uppercase; }
.fw-tag.ok { color: var(--fw-ok); background: color-mix(in srgb, var(--fw-ok) 10%, white); }
.fw-tag.warn { color: var(--fw-warn); background: color-mix(in srgb, var(--fw-warn) 10%, white); }
.fw-tag.bad { color: var(--fw-bad); background: color-mix(in srgb, var(--fw-bad) 10%, white); }
.fw-summary { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; font-size: 14px; margin: 0; }
.fw-summary > :nth-child(even) { text-align: right; margin: 0; }
.fw-summary dt, .fw-summary > :nth-child(odd) { color: var(--fw-muted); }
.fw-alert { margin: 0; padding: 12px 14px; border-radius: 12px; background: color-mix(in srgb, var(--fw-bad) 7%, white); border: 1px solid color-mix(in srgb, var(--fw-bad) 25%, white); color: var(--fw-bad); font-size: 14px; }
.fw-warn { margin: 0; padding: 12px 14px; border-radius: 12px; background: color-mix(in srgb, var(--fw-warn) 8%, white); color: #8a6d2f; font-size: 14px; }
.fw-primary { border: 0; cursor: pointer; padding: 15px 20px; border-radius: 13px; font: 600 16px var(--fw-font); color: var(--fw-on-brand); background: linear-gradient(110deg, var(--fw-brand), var(--fw-brand-2)); }
.fw-primary:disabled { background: #b9c7dd; cursor: not-allowed; }
.fw-secondary { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--fw-border); background: var(--fw-card); color: var(--fw-text); padding: 13px 18px; border-radius: 12px; font: 600 15px var(--fw-font); text-decoration: none; cursor: pointer; }
.fw-btn-small { border: 0; background: var(--fw-surface); color: var(--fw-brand); border-radius: 8px; padding: 6px 10px; font: 12px var(--fw-mono); cursor: pointer; align-self: flex-start; }
.fw-link { border: 0; background: none; padding: 0; color: var(--fw-brand); font: inherit; text-decoration: underline; cursor: pointer; text-align: left; }
.fw-icon-btn { border: 0; background: none; font-size: 26px; line-height: 1; cursor: pointer; color: var(--fw-muted); }
.fw-row-inline { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
:focus-visible { outline: 2px solid var(--fw-brand); outline-offset: 2px; }

/* Order */
.fw-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 13px; color: var(--fw-muted); }
.fw-steps li::before { content: "○ "; }
.fw-steps li.done { color: var(--fw-ok); }
.fw-steps li.done::before { content: "✓ "; }
.fw-steps li.now { color: var(--fw-text); font-weight: 600; }
.fw-steps li.now::before { content: "● "; color: var(--fw-brand); }
.fw-status { border-radius: var(--fw-radius); padding: 14px 16px; background: var(--fw-surface); border: 1px solid var(--fw-border); }
.fw-status p { margin: 6px 0 0; color: var(--fw-muted); font-size: 14px; line-height: 1.5; }
.fw-status.ok { border-color: color-mix(in srgb, var(--fw-ok) 35%, white); }
.fw-status.bad { border-color: color-mix(in srgb, var(--fw-warn) 40%, white); background: color-mix(in srgb, var(--fw-warn) 6%, white); }
.fw-deposit { display: flex; flex-direction: column; gap: 10px; border: 1px solid var(--fw-border); border-radius: var(--fw-radius); padding: 16px; }
.fw-big { font-size: 24px; }
.fw-timer { display: flex; flex-direction: column; gap: 2px; border: 1px solid var(--fw-border); border-radius: 12px; padding: 10px 12px; }
.fw-timer.urgent { background: #fff6ec; border-color: #f2d2ae; color: #9a5a2c; }
.fw-address { display: flex; gap: 12px; align-items: flex-start; }
.fw-address-main { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.fw-qr { border-radius: 8px; border: 1px solid var(--fw-border); flex-shrink: 0; }
.fw-note { margin: 0; font-size: 13px; color: var(--fw-brand); }
.fw-memo { background: #fff8e8; border: 1px solid #f2dfb0; border-radius: 12px; padding: 12px; }
.fw-txs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.fw-txs li { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }

/* Token picker */
.fw-overlay { position: fixed; inset: 0; background: rgba(10, 26, 64, 0.35); display: flex; align-items: center; justify-content: center; padding: 16px; z-index: 50; }
.fw-dialog { background: var(--fw-card); width: 100%; max-width: 460px; max-height: min(640px, 92vh); border-radius: 20px; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.fw-dialog-head { display: flex; justify-content: space-between; align-items: center; }
.fw-chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; flex-shrink: 0; }
.fw-dialog > .fw-input, .fw-dialog-head { flex-shrink: 0; }
.fw-chip { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--fw-border); background: var(--fw-card); border-radius: 999px; padding: 5px 10px; font: 13px var(--fw-font); color: var(--fw-text); cursor: pointer; }
.fw-chip.on { border-color: var(--fw-brand); color: var(--fw-brand); }
.fw-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; min-height: 200px; }
.fw-row { display: flex; align-items: center; gap: 10px; padding: 9px 8px; border-radius: 10px; cursor: pointer; }
.fw-row.active { background: var(--fw-surface); }
.fw-row-main { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.fw-row-net { font-size: 12px; text-align: right; }
.fw-face { position: relative; display: inline-flex; flex-shrink: 0; }
.fw-img { border-radius: 50%; display: block; object-fit: cover; background: color-mix(in srgb, var(--fw-brand) 8%, white); }
.fw-badge { position: absolute; right: -3px; bottom: -3px; border-radius: 50%; border: 2px solid var(--fw-card); background: var(--fw-card); display: flex; }
.fw-tile { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; background: color-mix(in srgb, var(--fw-brand) 14%, white); color: var(--fw-brand); font-weight: 700; flex-shrink: 0; }

@media (max-width: 420px) {
  .fw-card { padding: 16px; }
  .fw-amount { font-size: 22px; }
  .fw-address { flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
```

### `app/api/float-webhook/route.ts` and `lib/webhook.ts` — the webhook receiver

```ts
/**
 * Float webhook receiver: POST /api/float-webhook
 *
 * Register this URL once (scripts/register-webhook.mjs, or the dashboard:
 * API for developers → Webhooks), put the returned secret in
 * FLOAT_WEBHOOK_SECRET, and Float POSTs `order.state_changed`,
 * `payout.updated` and `webhook.test` events here.
 *
 *   1. verify Float-Signature (HMAC-SHA256 over "<t>.<raw body>", 5-minute tolerance,
 *      either v1 during a secret rotation) — on the RAW body, before parsing;
 *   2. de-duplicate on the event id (identical on every retry) — an id counts as
 *      seen only once it was handled, so a failed attempt is retried by Float;
 *   3. answer 2xx fast (within 10 s); do slow work afterwards.
 *
 * Docs: https://www.myfloat.cc/docs/api#6b-webhooks
 */

import { verifyFloatWebhook } from "@/lib/webhook";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/* Seen event ids. In memory for the starter — use your database (a unique
   key on the event id) in production, so a restart does not forget them. */
const seen = new Set<string>();

type FloatEvent =
  | { id: string; type: "order.state_changed"; created_at: string; data: { previous_state: string | null; order: { claimToken: string; state: string } } }
  | { id: string; type: "payout.updated"; created_at: string; data: { previous_status: string | null; payout: { id: string; status: string; amountCc: string } } }
  | { id: string; type: "webhook.test"; created_at: string; data: { endpoint_id: string; message: string } }
  | { id: string; type: string; created_at: string; data: unknown };

export async function POST(req: Request): Promise<Response> {
  const secret = process.env.FLOAT_WEBHOOK_SECRET?.trim();
  if (!secret) return new Response("FLOAT_WEBHOOK_SECRET is not set", { status: 503 });

  const raw = await req.text(); // the exact bytes Float signed
  if (!verifyFloatWebhook(raw, req.headers.get("float-signature"), secret)) {
    return new Response("bad signature", { status: 400 });
  }
  let event: FloatEvent;
  try {
    event = JSON.parse(raw) as FloatEvent;
  } catch {
    return new Response("body is not JSON", { status: 400 });
  }
  if (!event || typeof event !== "object" || typeof event.id !== "string" || !event.id || event.id.length > 200 || typeof event.type !== "string") {
    return new Response("not a Float event", { status: 400 });
  }
  if (seen.has(event.id)) return new Response(null, { status: 200 }); // a retry we already handled

  try {
    handle(event);
  } catch (e) {
    /* Not marked as seen: answer 5xx and Float retries this event later. */
    console.error("[float] webhook handling failed", e);
    return new Response("handler failed", { status: 500 });
  }
  if (seen.size > 10_000) seen.clear();
  seen.add(event.id);
  return new Response(null, { status: 200 });
}

function handle(event: FloatEvent): void {
  switch (event.type) {
    case "order.state_changed": {
      const { order, previous_state } = (event as Extract<FloatEvent, { type: "order.state_changed" }>).data;
      /* `order` is exactly what GET /v1/order/{claimToken} returns. Update your
         record of this order and notify your user. Act on order.state — you may
         not see every intermediate state. */
      console.log(`[float] order ${order.claimToken.slice(0, 8)}…: ${previous_state ?? "∅"} → ${order.state}`);
      break;
    }
    case "payout.updated": {
      const { payout } = (event as Extract<FloatEvent, { type: "payout.updated" }>).data;
      console.log(`[float] payout ${payout.id}: ${payout.status} (${payout.amountCc} CC)`);
      break;
    }
    case "webhook.test":
      console.log("[float] webhook test received");
      break;
    default:
      /* New event types may be added: ignore what you do not handle. */
      break;
  }
}
```

```ts
/**
 * Verify a Float webhook delivery. Float-Signature is "t=<unix seconds>,v1=<hex>";
 * the hex is HMAC-SHA256, keyed with the endpoint's whole "whsec_…" secret,
 * over "<t>.<raw body>". For 24 h after a secret rotation there are two v1
 * values — accept if either matches. Server only.
 * https://www.myfloat.cc/docs/api#verify-the-signature
 */

import crypto from "node:crypto";

export function verifyFloatWebhook(rawBody: string, header: string | null, secret: string, toleranceSeconds = 300): boolean {
  if (!header) return false;
  let t: number | null = null;
  const sigs: string[] = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i < 0) continue;
    const k = part.slice(0, i).trim();
    const v = part.slice(i + 1).trim();
    if (k === "t" && /^\d+$/.test(v)) t = Number(v);
    else if (k === "v1") sigs.push(v);
  }
  /* Float sends one v1, or two during a secret rotation — never more. */
  if (t === null || sigs.length === 0 || sigs.length > 2) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody, "utf8").digest();
  return sigs.some((s) => {
    const got = Buffer.from(s, "hex");
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}
```

### `scripts/register-webhook.mjs` — register the receiver once

```js
#!/usr/bin/env node
/**
 * Register this app's webhook receiver with Float, once.
 *
 *   FLOAT_API_KEY=fl_live_… node scripts/register-webhook.mjs https://your-app.example/api/float-webhook
 *
 * Prints the signing secret ONCE: put it in FLOAT_WEBHOOK_SECRET (server-side
 * env / secret store). Then sends a test event. The URL must be public https
 * (no localhost); up to 5 endpoints per account. Every change made with a key
 * is emailed to your Float account.
 */
const BASE = (process.env.FLOAT_API_BASE || "https://api.myfloat.cc/v1").replace(/\/+$/, "");
const KEY = process.env.FLOAT_API_KEY;
const url = process.argv[2];
if (!KEY || !url) {
  console.error("usage: FLOAT_API_KEY=fl_live_… node scripts/register-webhook.mjs https://your-app.example/api/float-webhook");
  process.exit(1);
}
const call = async (path, init = {}) => {
  const r = await fetch(BASE + path, { ...init, headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json", ...(init.headers ?? {}) } });
  const j = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(`${r.status} ${j.code ?? ""}: ${j.error ?? "request failed"}`);
  return j;
};
const created = await call("/webhooks", { method: "POST", body: JSON.stringify({ url }) });
console.log(`Registered ${created.endpoint.url} (${created.endpoint.id}).`);
console.log(`\nFLOAT_WEBHOOK_SECRET=${created.secret}\n`);
console.log("Store it now — Float shows it only once. Then redeploy and run:");
console.log(`  curl -X POST ${BASE}/webhooks/${created.endpoint.id}/test -H "Authorization: Bearer $FLOAT_API_KEY"`);
```

### `app/page.tsx` and `app/layout.tsx`

```tsx
import FloatWidget from "@/components/FloatWidget";

/* Put <FloatWidget /> wherever it should live in your app: a page, a modal, a tab. */
export default function Page() {
  return (
    <main className="fw-page">
      <h1 className="fw-title">Swap</h1>
      <FloatWidget />
      <p className="fw-powered">Swaps powered by <a href="https://www.myfloat.cc">Float</a></p>
    </main>
  );
}
```

```tsx
import type { Metadata, Viewport } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Swap with Float",
  description: "Get CC or withdraw CC — any of ~2,000 assets, best rate across Float's partners.",
};
export const viewport: Viewport = { width: "device-width", initialScale: 1 };

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
```

### `package.json`, `.env.example`, `next.config.mjs`, `tsconfig.json`

```json
{
  "name": "float-widget-starter",
  "version": "1.0.0",
  "private": true,
  "description": "Float swap widget for your own Next.js app: a key-holding proxy, the swap flow and a token picker.",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "16.2.9",
    "qrcode": "1.5.4",
    "react": "19.2.0",
    "react-dom": "19.2.0"
  },
  "devDependencies": {
    "@types/node": "22.20.1",
    "@types/qrcode": "1.5.6",
    "@types/react": "19.2.18",
    "@types/react-dom": "19.2.7",
    "typescript": "5.9.3"
  }
}
```

```sh
# Your Float API key, from https://www.myfloat.cc/partners -> API for developers.
# SERVER ONLY. Never prefix it with NEXT_PUBLIC_, never commit .env.local.
FLOAT_API_KEY=

# Optional. Float's partner API base (no trailing slash).
FLOAT_API_BASE=https://api.myfloat.cc/v1

# The signing secret Float returns when you register your webhook endpoint
# (node scripts/register-webhook.mjs <url>). Server only.
FLOAT_WEBHOOK_SECRET=
```

```js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  /* This folder is its own app: never let Next pick a parent directory's
     lockfile as the workspace root. */
  turbopack: { root: import.meta.dirname },
  outputFileTracingRoot: import.meta.dirname,
};

export default nextConfig;
```

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": false,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
  "exclude": ["node_modules"]
}
```

### `README.md`

````md
# Float widget starter (Next.js)

Float's swap flow for your own app: **Get CC** (any asset → Canton Coin) and
**Withdraw CC** (CC → any asset), at the best rate across Float's liquidity
partners. You earn 0.2% of the CC side of every order opened with your key.

- Reference: https://www.myfloat.cc/docs/api
- OpenAPI 3.1: https://www.myfloat.cc/openapi.json
- Build it with an AI assistant: https://www.myfloat.cc/docs/ai

## What is in here

| File | What it does |
|---|---|
| `app/api/float/[...path]/route.ts` | The key-holding proxy. The browser calls `/api/float/…`; this route calls Float's `/v1` API with `FLOAT_API_KEY`. Only the five partner calls are allowed through, from this site's own pages, within a per-IP limit (see [The proxy's guards](#the-proxys-guards)). |
| `components/FloatWidget.tsx` | The swap flow: tabs, amount, token picker, live quote and routes, Exchange (with an `Idempotency-Key`), deposit step (address, exact amount, countdown, QR), status until final. |
| `components/TokenPicker.tsx` | Search, popular first, network chips with logos, token logo with chain badge, keyboard support. Uses `/v1/assets?all=1`. |
| `lib/float.ts` | Types (mirroring the OpenAPI spec), the API client, decimal display helpers, the deposit window, and the human copy for every order state. |
| `app/api/float-webhook/route.ts`, `lib/webhook.ts` | Webhook receiver: verifies `Float-Signature`, de-duplicates on the event id. |
| `scripts/register-webhook.mjs` | Registers the receiver with your key and prints the signing secret once. |
| `app/globals.css` | All styling. Brand it with the `--fw-*` variables at the top. |

## Run it

```bash
npm install
cp .env.example .env.local     # then paste your key into FLOAT_API_KEY
npm run dev                    # http://localhost:3000
```

Without a key, quotes and the token list work (but your whole server shares
Float's anonymous limit of about 12 requests a minute) and **Exchange is disabled**
(the proxy answers `503 proxy_not_configured`): an order opened without your
key would not be attributed to you, and `Idempotency-Key` needs a key.

Create a key at https://www.myfloat.cc/partners → **API for developers**. It
is shown once. Keep it in `.env.local` / your host's secret store only —
never in client code, a `NEXT_PUBLIC_` variable, or the repo.

## The proxy's guards

`/api/float/…` spends **your** key and **your** rate limit, so the starter
keeps it for this site's own pages. These are starter-grade guards — enough to
stop casual abuse, not a security boundary. If your app has signed-in users,
also put the route behind your own auth.

- **Same origin.** A request whose `Origin` (or, without one, `Referer`) is
  another site gets `403 forbidden_origin`; a `POST` without either is refused
  too. The app's own origin comes from the `Host` header (or
  `X-Forwarded-Host` / `X-Forwarded-Proto` behind a proxy). Serving the widget
  from more than one origin? List the others:
  `FLOAT_PROXY_ALLOWED_ORIGINS=https://app.example.com,https://example.com`.
- **Per-IP limit.** `FLOAT_PROXY_RATE_LIMIT` requests a minute per IP
  (default 60), then `429 rate_limited` with `Retry-After`. It is kept in this
  server's memory: per instance and reset on restart. On serverless or with
  several instances, use your platform's rate limiting or a shared store
  (Redis, …). The IP is the first `X-Forwarded-For` entry — only trust that
  behind a proxy that sets it (Vercel, Netlify, most load balancers do).
- **Body cap.** Request bodies over 16 KB get `413 payload_too_large`.
- **Idempotency-Key.** Passed through only when it is 1–255 visible ASCII
  characters; a malformed one is `400 idempotency_key_invalid` (never dropped,
  which would turn a safe retry into a second order).
- **Timeouts.** If Float does not answer `POST /order` within 30 s the proxy
  answers `504 upstream_timeout`: the order may or may not have opened. The
  widget retries with the **same** `Idempotency-Key` and body, which returns
  the order if it opened and opens it once if it did not.

Error sentences from Float (`error`) are plain text: the widget renders them as
React text nodes. Never put them in `innerHTML` / `dangerouslySetInnerHTML`.

## Test checklist

Opening an order moves no money, but it does open a real order at a swap
partner. Test with small amounts and **do not send a deposit unless you mean
to swap**.

- [ ] Token picker opens, search finds `usdt` and `bitcoin`, network chips filter, Esc closes, ↑/↓/Enter in the search box pick; Enter on a chip only filters; Tab stays inside the picker; closing it returns focus to the token button.
- [ ] Logos show; a token without one shows a lettered tile.
- [ ] Typing an amount shows "Asking every partner for a quote…", then routes with BEST RATE first.
- [ ] An amount below the minimum shows Float's message ("That amount is below every venue's minimum…").
- [ ] Fixed rate shows the "Fewer partners offer a fixed rate" hint.
- [ ] A bad Canton party ID on Get CC is refused before any request.
- [ ] With a key: Exchange opens the order and shows the deposit step: exact amount, address, countdown, QR. On Withdraw CC: "Valid for this order only", no memo.
- [ ] Reloading the page with `?order=<claimToken>` reopens the order.
- [ ] The status card updates by itself (polling every 15 s; 5 s while the address is being prepared).
- [ ] Withdraw CC to XRP (or XLM, TON, ATOM…) shows the destination tag / memo field and its warning; BTC does not. A malformed tag (e.g. letters for XRP) shows Float's 400 message and the field stays. Switching to BTC clears the tag and quotes BTC normally.
- [ ] Fee shows the selected route's fee (pick an alternative: it changes); a 0 fee reads "0%".
- [ ] After a final state, **New order** re-quotes and Exchange opens a new order (a new `Idempotency-Key`), not the old one.
- [ ] `curl -X POST localhost:3000/api/float/quote -H 'origin: https://evil.example' …` gets `403`.
- [ ] At 375 px wide nothing scrolls sideways.
- [ ] Webhooks: a POST to `/api/float-webhook` with a wrong or missing `Float-Signature` gets `400`; the dashboard's **Send test event** (or `POST /v1/webhooks/{id}/test`) gets `200` and logs "webhook test received".

## Webhooks

`app/api/float-webhook/route.ts` receives Float's events (`order.state_changed`,
`payout.updated`, `webhook.test`), verifies `Float-Signature` on the raw body
(at most two `v1` values), rejects a body that is not JSON or has no event `id`
(`400`), de-duplicates on the event id — marking it seen only after it was
handled, so a failed attempt (`500`) is retried by Float — and answers fast.

1. Deploy the app so `https://<your-domain>/api/float-webhook` is public
   (webhook URLs must be public `https://`; no localhost).
2. Register it once, with your key:
   `FLOAT_API_KEY=fl_live_… node scripts/register-webhook.mjs https://<your-domain>/api/float-webhook`
3. Put the printed secret in `FLOAT_WEBHOOK_SECRET` (it is shown only once) and redeploy.
4. Send a test event (the script prints the command) and check your logs.

Polling stays the source of truth: the widget keeps polling the order it is
showing; the webhook is how your backend learns about every order.

## Destination tag / memo

On Withdraw CC, when the chosen asset (or the quote) says
`destinationExtraIdNeeded`, the widget shows a field named by
`destinationExtraIdName` ("Destination tag" for XRP, else "Memo") with the
warning "Sending to an exchange? Add your destination tag/memo, or the funds
may be lost." and sends it as `destinationExtraId` on the quote (partners that
cannot carry it drop out) and on the order — only then: a tag is never sent for
an asset that takes none. Picking another asset or network clears it, and
Exchange waits for a quote that has seen the tag as typed.
````
