Ferryline

Widget

The framework-agnostic <ferryline-widget> custom element — a real integration example, theming, and its real limitations.

@ferryline/widget ships one thing: a real <ferryline-widget> custom element that wraps @ferryline/sdk's quote → build → sign → track lifecycle (see Core Concepts) in a ready-made UI. It's a plain Web Component — FerrylineWidget extends HTMLElement, defined via defineFerrylineWidget() — with no framework runtime underneath it. There's no .tsx file anywhere in the package and no react dependency in its package.json; it works the same way inside a React app, a Vue app, or a static HTML page with no framework at all.

There's currently no React wrapper around it. The package's own scope note is explicit that this is a decision for this phase, not an oversight:

This package intentionally does NOT include a React wrapper, and testnet mode intentionally does NOT offer USDT0 (see above for why the latter is structural, not a policy choice). Both are explicit decisions for this phase, not oversights — see the repo's phase reports if you're deciding whether to add either downstream.

If you need the widget inside a React tree today, render the custom element directly (<ferryline-widget> works fine in JSX) and set .request imperatively via a ref, the same way any other custom element is used from React without a dedicated wrapper.

Want to see it running before writing any integration code at all? See Playground for a real, live instance you can try right now, real testnet transfer, real protocol inspector, no local setup.

Install and register

npm install @ferryline/widget
import { defineFerrylineWidget } from "@ferryline/widget";
defineFerrylineWidget(); // registers <ferryline-widget>; safe to call more than once

A real integration example

Declarative attributes handle network configuration; the transfer itself is set imperatively as a property, because a full TransferRequest (see SDK Reference) doesn't serialize cleanly to a single HTML attribute. This is the package's own README example, verbatim:

<ferryline-widget network="testnet"></ferryline-widget>
<script type="module">
  const widget = document.querySelector("ferryline-widget");
  widget.request = {
    asset: "USDC",
    from: { chain: "stellar", address: "G..." },
    to: { chain: "ethereum-sepolia", address: "0x..." },
    amount: "10",
    parameters: { maxFee: "0", minFinalityThreshold: 2000 },
  };
</script>

Setting .request kicks off a real quote() call immediately.

For a more complete picture of a host page driving this, packages/widget/e2e/harness.ts is the real script this repo's own Playwright end-to-end run uses — it defines the element, then sets the exact same TransferRequest shape against a real testnet outbound CCTP send (Stellar → Ethereum Sepolia):

import { defineFerrylineWidget, type FerrylineWidget } from "../src/index.js";
import type { TransferRequest } from "@ferryline/sdk";

defineFerrylineWidget();

const SENDER = "GBBA3HN2PNOAJGR6R5VY34SQFDFTZFQIGDPYATJB34UXXFUHVR4KZRAZ";
const RECIPIENT_EVM = "0x11598bA8E624b7df301FC3d2C71165A076b7480e";

const widget = document.querySelector<FerrylineWidget>("#widget")!;

const request: TransferRequest = {
  asset: "USDC",
  from: { chain: "stellar", address: SENDER },
  to: { chain: "ethereum-sepolia", address: RECIPIENT_EVM },
  amount: "0.5",
  parameters: { maxFee: "0", minFinalityThreshold: 2000 },
};

window.__ferrylineTestHooks = {
  setRequest(): void {
    widget.request = request;
  },
};

Its own doc comment is explicit that this isn't a synthetic fixture: "No mocks, no test doubles — the only thing this file adds beyond what a real integrator's page would do is exposing a couple of read hooks (window.__ferrylineTestHooks)" for the Playwright script to read rendered state.

Attributes

AttributeValuesDefaultNotes
networktestnet | mainnettestnetChanging it tears down and rebuilds the widget's internal client/wallet session.
rpc-urla Soroban RPC URLnetwork defaultOverride if you run your own RPC node.
relayer-urla Ferryline relayer base URL—Used for both directions: required for inbound (EVM → Stellar) CCTP tracking (see registerInboundTransfer below), and enables automatic outbound (Stellar → EVM) registration if set (see below).
relayer-api-keya bearer token—Sent as Authorization: Bearer <key> to the relayer.
prepare-step-poll-max-attemptsa positive integer20See Polling before the deferred burn step below. Raise it if your own RPC node has worse allowance-visibility lag than this project's testing observed.
prepare-step-poll-interval-msmilliseconds, a positive integer1500Same section as above. A non-positive or unparseable value on either attribute falls back to its real default rather than producing a poll that never runs or runs forever.

In network="testnet" mode, only the usdc-cctp rail is ever registered — the widget's internal client only constructs a UsdcCctpAdapter in testnet mode, so there's no code path where a testnet widget instance even holds a USDT0 adapter to route to (see Core Concepts for why USDT0 has no Stellar testnet deployment at all). In network="mainnet" mode, both rails are registered.

Registering an inbound transfer

Inbound EVM → Stellar CCTP transfers are different from outbound: the widget doesn't drive the source-chain wallet (that's whatever wallet the user already used on the EVM side), so it needs a self-hosted relayer to watch that chain and complete delivery on Stellar. Once you have a real EVM burn transaction hash, call:

await widget.registerInboundTransfer(transferId, "ethereum-sepolia", sourceTxHash);

This calls the configured relayer's real POST /transfers, then polls GET /transfers/:id, rendering live status inline (the inbound-status part below). If no relayer-url attribute or property is set, it throws immediately rather than silently doing nothing — the exact current source (it reads the relayer config through the widget's own SDK client, not a direct attribute getter, since the outbound flow below needed the same config value and this was the real migration that let both directions share it):

async registerInboundTransfer(
  transferId: string,
  sourceChain: ChainSlug,
  sourceTxHash: string,
): Promise<void> {
  const relayerUrl = this.client().ferryline.config.relayerUrl;
  if (!relayerUrl) {
    throw new Error("registerInboundTransfer requires a relayer-url attribute or property");
  }
  // ...
}

Polling before the deferred burn step

For a fresh account (no prior USDC allowance to the TokenMessengerMinter), the outbound CCTP flow is two Stellar transactions submitted back to back from the same account: approve, then the actual deposit_for_burn. The burn's own on-chain footprint can't be simulated until the approve's allowance genuinely exists, so it's assembled by a real, separate prepareStep call after the approve confirms, not built up front alongside it.

This used to be a fixed-timer wait (POST_APPROVE_BUILD_DELAY_MS, 15 seconds in @ferryline/widget@0.1.0/0.1.1), removed after an external, independent integration test caught it failing 100% of the time on a genuinely fresh testnet account at that shipped default — the fixed timer had, in fact, never been validated against a real, live, two-step testnet transfer before shipping. See packages/widget/CHANGELOG.md's 0.1.2 entry for the full incident writeup.

What it does now: polls prepareStep itself, retrying only on the one specific, real, typed error it throws exactly when the chain state it needs isn't there yet — FerrylineError with code: "ALLOWANCE_INSUFFICIENT" (documented directly on RailAdapter.prepareStep's own interface in @ferryline/core). Every other failure — including a genuine STEP_NOT_READY (a real caller bug, not a timing issue) — is rethrown immediately, on the first attempt, never retried. prepare-step-poll-max-attempts and prepare-step-poll-interval-ms (see Attributes above) are the real, public config for this poll's bound and interval; both are live-validated against a real testnet transfer, not just unit-tested — see the real, dated experiment record for the real transaction hashes and observed retry count/timing.

Outbound registration (automatic)

Unlike inbound, you don't call anything for outbound. Once the final step of an outbound (Stellar → EVM) transfer is signed, submitted, and its hash recorded, the widget automatically calls @ferryline/sdk's Ferryline.registerOutboundTransfer() on your behalf. The code below is the real, current body of packages/widget/src/index.ts's own afterStepSubmitted, copied verbatim (only re-indented; its own inline comments are trimmed here — the full version explains the historical double-registration bug this exact isFinalStep check exists to prevent, the poll before the deferred step described above, and the request-generation staleness guard, see that method's own doc comment in the real file for the complete story). isFinalStep is imported from @ferryline/sdk — the same canonical helper documented on the SDK Reference page — this file does not hand-roll its own version of that comparison:

if (!isFinalStep(built, stepIndex)) {
  const nextIndex = stepIndex + 1;
  this.setPhaseIfCurrent(
    signedAwaitingNextStep({ quote, built, stepIndex: nextIndex }),
    generation,
  );
  // [comment trimmed — see the real source for the full explanation]
  const nextStep = await pollPrepareStep(
    () => this.client().ferryline.prepareStep(built.transferId, nextIndex),
    isAllowanceInsufficient,
    this.prepareStepPollMaxAttempts,
    this.prepareStepPollIntervalMs,
  );
  const rebuilt = {
    ...built,
    steps: built.steps.map((s, i) => (i === nextIndex ? nextStep : s)),
  };
  this.setPhaseIfCurrent(buildSucceeded(quote, rebuilt, nextIndex), generation);
  return;
}
// [comment trimmed — see the real source for the full explanation]
const registrationResult = await this.client().ferryline.registerOutboundTransfer(built.transferId);
if (generation === this.#requestGeneration) {
  this.#outboundRegistrationResult = registrationResult;
}

This is gated on relayer-url being configured, silently, the same way registerOutboundTransfer itself is (see SDK Reference): no relayer configured means no registration attempt, not an error. With a relayer configured, the transfer now normally completes delivery on its own — see Known limitations below for the honest edge of that claim.

registerOutboundTransfer() returns a real { registered: boolean, error?: string } result (not void) specifically so the widget can tell a genuine registration success apart from a genuine failure — a real, live 401 from a misconfigured relayer API key used to still render success text, before this existed. The widget's own delivery-status copy reflects all three real cases, from its own outboundDeliveryCaveat(relayerConfigured, registrationResult) helper: no relayer configured at all ("not automatic for this rail"); relayer configured and registration genuinely succeeded ("has been registered"); relayer configured but registration genuinely failed (or hasn't resolved yet) — a distinct, honest "NOT currently registered" message, never the success claim in that case.

Theming

The widget's styling mechanism changed since an earlier version of this page: it's now real Tailwind v4, compiled at build time, not hand-authored CSS custom properties for every visual token. Get this right before touching your own theme, because the two mechanisms have genuinely different override surfaces.

The real current mechanism

packages/widget/src/style.css is a real Tailwind source file (@import "tailwindcss" source(none)

  • @config "../tailwind.config.ts"), compiled by packages/widget/scripts/build-style.mjs into a static string, GENERATED_STYLE, written to src/generated-style.ts and injected into the shadow root's <style> tag at render time — build:style runs before tsup in the package's own build script, and fails the build outright on any Tailwind warning. tailwind.config.ts imports its colors/radius/duration/easing directly from @ferryline/design-tokens — the same real package apps/site and apps/docs build their own Tailwind themes from (see Core Concepts for the shared-tokens story). None of this ships to consumers: tailwindcss, @tailwindcss/postcss, and @ferryline/design-tokens are devDependencies of the widget package, not runtime ones.

CSS custom properties still exist, but for a smaller surface than before

8 properties remain real, live, and settable from outside the shadow root — down from an earlier 12, and several defaults changed along the way:

PropertyDefaultAffects
--ferryline-fontsystem-ui, sans-serifBase font family
--ferryline-font-size15pxBase font size
--ferryline-fg#000000Text color
--ferryline-bg#ffffffShell background
--ferryline-border-radius20pxCorner radius
--ferryline-muted#66696bSecondary text
--ferryline-danger#dc2626Error text
--ferryline-z-index1000Stacking context
ferryline-widget {
  --ferryline-border-radius: 12px;
  --ferryline-font: "Inter", sans-serif;
}

Four properties from the older table are dead — setting them now has zero visual effect. Disclosed plainly rather than left for you to discover by trial and error:

  • --ferryline-border — the shell has no border in the current design at all; it uses a drop shadow instead.
  • --ferryline-spacing — shell padding is a hardcoded Tailwind utility (p-6) now, not a variable.
  • --ferryline-accent — the primary call-to-action is hardcoded bg-brand (Ferryline's real indigo, #4F46E5, from @ferryline/design-tokens) with a hardcoded hover state; links use text-brand the same way.
  • --ferryline-accent-fg — the CTA's text color is hardcoded text-on-brand (#ffffff).

A real trade-off, not glossed over

This is a genuine regression in re-theming flexibility, worth stating plainly rather than minimizing: a downstream consumer can no longer recolor the primary button, links, shell border, or shell spacing via a CSS custom property at all. The only remaining override surface for those four is the coarser ::part() selectors below (ferryline-widget::part(build-button) { background: ... }), which don't unify CTA + link color the way the old --ferryline-accent token did in one place. In exchange, the widget's own visual identity now matches Ferryline's real brand exactly, by construction, with one real source of truth (@ferryline/design-tokens) instead of a second, independently-maintained set of hex values that could silently drift from it — which is exactly what happened to the old defaults table above before this rewrite. Which trade-off is right depends on whether you need to override the brand color at all; if you don't, this change is invisible to you.

Below 480px viewport width, the widget docks to the bottom of the viewport as a mobile bottom sheet (rounded top corners only, scrollable if content exceeds 85% of viewport height) — unchanged by the Tailwind migration. If you're embedding it inside your own bottom-sheet container at that width, override position / inset on the ferryline-widget element itself — the cascade allows it.

::part() selectors

Shadow-DOM part attributes are exposed for deeper styling with ::part(). There are 25 of them, read directly from src/index.ts:

shell, network, wallet-error, inbound-status, status, quote, preview, preview-heading, preview-summary, decoded-call, wallet-connect, wallet-module-button, build-button, cancel-button, confirm-button, tracking-progress, spinner, tracking-elapsed, source-tx, dest-tx, delivery-caveat, failure, reset-button, faucets, faucet-link

tracking-progress/spinner/tracking-elapsed are the visual "still working" signal during the tracking phase — a plain, indeterminate CSS spinner plus a live, ticking elapsed-time counter ((12s), (1m 05s)), closing a real gap: track()'s own polling loop can genuinely go minutes between real status updates (Circle's Iris attestation alone is not instant), and without a visual signal a user watching this has no way to tell "still working" apart from "stuck." Deliberately indeterminate, not a real progress percentage — there is no reliable total duration for a real delivery to measure a percentage against. Respects prefers-reduced-motion: the spinner's rotation stops, the elapsed-time text still updates.

ferryline-widget::part(shell) {
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}

Wallet support

Wired against Stellar Wallets Kit's real defaultModules() bundle: Albedo, Freighter, Fordefi, Rabet, xBull, LOBSTR, Hana, Klever, OneKey, Bitget, Cactus Link, D'CENT, Scopuly — whatever that function returns from the installed kit version, rendered as connect buttons inside the transaction preview step. Only one of these has actually been driven end-to-end through a real, installed browser extension: Freighter, via the package's own Playwright E2E run (e2e/) against real Stellar testnet infrastructure. The rest are wired identically through the same kit but have not been independently verified the same way — the README is explicit that confirming which ones actually work through a real installed extension is left to the repo's own phase reports, not assumed from the kit wiring alone.

Before any wallet ever sees a signTransaction call, the widget decodes and renders the real built step — contract/function for a Stellar invocation, the real ERC-20/TokenMessengerV2 call for an EVM step, never raw XDR or hex — behind an explicit Confirm/Cancel. This is structural, not just a UI convention: the internal state machine's confirmPreviewAndSign function is the only thing in the package that can produce a signing phase, and it's called from exactly one place in src/index.ts — the confirm-button handler, itself reachable only while the phase is preview.

Testnet mode and faucets

In network="testnet" mode, the widget surfaces two real, public testnet faucet links:

  • Friendbot (https://friendbot.stellar.org) — XLM
  • Circle's testnet faucet (https://faucet.circle.com) — USDC, supports Stellar testnet directly

Known limitations

No React wrapper (v0.x). Covered above — the scope note is explicit that this is deliberate for this phase, not an oversight.

No testnet USDT0. network="testnet" only ever registers the usdc-cctp rail. This isn't a widget-level restriction layered on top of a capable SDK — Usdt0LayerZeroAdapter's own constructor option type only accepts { network: "mainnet" }, because USDT0 has no Stellar testnet deployment to test against at all (see Core Concepts).

Outbound delivery isn't a guaranteed SLA, even with a relayer configured. A self-hosted Ferryline outbound relayer now exists (see Registering an outbound transfer above and Relayer), and the widget registers with it automatically — so outbound delivery normally completes on its own now, closing what used to be a real, unconditional gap. What hasn't changed: receiveMessage remains genuinely permissionless by CCTP's own design, per Circle's own technical guide —

An API consumer must query this attestation and submits it onchain to the destination domain's MessageTransmitterV2#receiveMessage function

— so if no relayer is configured, or the configured one is unavailable, misconfigured, or has hit its own daily gas ceiling, delivery does not complete itself. The sender, recipient, or an integrator's own infrastructure can still submit it manually at any time, exactly the same real fallback that existed before the outbound relayer shipped — see the end-to-end walkthrough for that manual path, and Security & Verification for the sourced writeup behind both the original finding and its resolution.

A note on what's unverified here

This page describes real, current source, rewritten once already after an earlier version was found to describe a pre-Tailwind-migration, pre-outbound-relayer state of this package. Two things in the current version are still worth treating differently from the rest, disclosed rather than glossed over: packages/widget/README.md's own "Theming" section was not updated by the Tailwind restyle (confirmed directly — the restyle commit doesn't touch that file), so it still describes the old 12-property table; this page's theming section was rewritten from the real source (tailwind.config.ts, style.css, build-style.mjs) rather than from that now-stale README section, and the README itself still needs the same fix. And the wallet module list beyond Freighter is wired but not independently confirmed against a real installed extension — take that one at face value as "wired, not (yet) proven."

On this page