Ferryline

Relayer

The self-hosted service that completes CCTP transfers in both directions (inbound and outbound) — Docker Compose setup, environment variables, CORS, and the HTTP API.

ferryline-relayer (packages/relayer) is a self-hosted Node service that completes CCTP transfers in two independent directions, run in the same process:

  • Inbound (EVM → Stellar) — watches the source burn transaction, polls Circle's Iris for the attestation, and once the message is verified, builds and pays for the mint_and_forward call on Stellar itself, fee-bumping it from its own sponsor account so the recipient never needs XLM to receive the transfer.
  • Outbound (Stellar → EVM) — the mirror image: watches a Stellar-source burn, polls Iris for its attestation, and once verified, submits and pays for the receiveMessage call on the destination EVM chain, from its own EVM sponsor account.

As the README's own opening line puts it: "Completes CCTP transfers on the sender's behalf, in two independent directions." Outbound is real, testnet-proven (a real Sepolia receiveMessage transaction), and opt-in per deployment — FERRYLINE_OUTBOUND_ENABLED=true — not the default, since a deployment that only wants inbound shouldn't be forced to configure an EVM signer/RPC/spend caps it will never use.

This exists because CCTP itself has no automatic delivery on either end by default — see Core Concepts for why an API consumer has to submit the attestation onchain themselves. The relayer is that submitter, running continuously in whichever direction(s) you enable, so nobody else has to.

The rail scope is still narrow on purpose: the relayer's own SUPPORTED_RAILS (in src/http/schemas.ts) is ["usdc-cctp"] only, with a one-line reason attached — usdt0-layerzero never needs a relayer, because "LayerZero's own executor delivers it." That hasn't changed; only the CCTP side gained a second direction.

For the full design reasoning behind the outbound direction specifically, see the relayer's own OUTBOUND_SCOPE.md and OUTBOUND_THREAT_MODEL.md (packages/relayer/) — real, current, detailed design docs this page draws from but doesn't fully reproduce.

The transfer state machine

Both directions share the exact same real status type (packages/relayer/src/repo/types.ts): "pending" | "attested" | "submitting" | "delivered" | "failed". Every transfer, either direction, moves through this:

The submitting transition happening before confirmation, with the real transaction hash already recorded, is deliberate crash-safety (work/submit.ts's and work/outbound-submit.ts's own real doc comments both call this out explicitly): if the relayer process dies right after broadcasting, the row is never stuck in limbo — a restart's reconciliation pass can resolve it by checking whether that specific hash actually landed, rather than re-submitting blind.

How each direction actually completes

The two directions are structurally different — who burns, who watches, who pays for the completing transaction all swap — so they're kept as two separate diagrams rather than forced into one that would blur which side does what.

Inbound (EVM → Stellar)

Outbound (Stellar → EVM)

Both loops are real, current function names (packages/relayer/src/work/attest.ts, submit.ts, outbound-attest.ts, outbound-submit.ts) — the outbound pair mirrors the inbound pair deliberately, down to the crash-safety ordering, not just conceptually.

The security property: amount and recipient are never trusted from the caller

POST /transfers accepts exactly four fields: transferId, sourceChain, sourceTxHash, and rail — which source transaction to watch, nothing about what it's worth or who receives it. This is a deliberate design decision, called out explicitly in both the README and the request schema itself. From the README:

The transfer's actual amount and recipient are extracted only once the transfer reaches attested, from the independently-parsed and verified on-chain CCTP message and its forwarder hook data. They are null on the row until that transition writes them. If a caller could register a transfer claiming a recipient or amount that doesn't match what actually happened on-chain, the relayer's spend cap and per-recipient rate-limit decisions could be made against a fabricated fact instead of a verified one.

And the schema itself, registerTransferBodySchema in src/http/schemas.ts, carries the same warning as a code comment aimed at future editors:

SECURITY PROPERTY, not an oversight — do not "simplify" this by adding amount/recipient here as a convenience: this schema deliberately accepts ONLY which source transaction to watch.

Practically, this means GET /transfers/:id reports amount: null and recipient: null for any transfer still in pending — both fields populate only once the row reaches attested.

Two rate limits — don't conflate them

Because the recipient genuinely isn't known at registration time, the relayer enforces two separate limits, checked at two different points, and only one of them is a per-recipient control:

  • Registration-time, per API key (FERRYLINE_REGISTRATION_LIMIT_MAX_ATTEMPTS / FERRYLINE_REGISTRATION_LIMIT_WINDOW_MS, default 60 attempts / 60 seconds — see src/spend/registration-limit.ts). Checked on every POST /transfers call, before the recipient exists. The README is explicit that this is a blunt instrument: it "has no idea who the money is going to, only which API key made the call" — its job is stopping one caller from flooding the relayer with junk registrations, and a 429 here is not evidence about any specific recipient.
  • At the pending → attested transition, per recipient (FERRYLINE_MAX_TRANSFERS_PER_RECIPIENT / FERRYLINE_RECIPIENT_RATE_LIMIT_WINDOW_MS, no default — required). This is the real per-recipient limit, checked only once the recipient is verified from the on-chain message. A recipient over this limit gets a terminal errorCode: "RECIPIENT_RATE_LIMITED" on that transfer's row, distinct from every other failure reason — check GET /transfers/:id, not the registration response, to see it.

Outbound (Stellar → EVM)

Outbound registration mirrors inbound closely, with the source/destination sides swapped. Once you've submitted a Stellar-source CCTP burn (directly, or via @ferryline/sdk's UsdcCctpAdapter), you register it with the relayer — either yourself, or automatically, since @ferryline/sdk's Ferryline.registerOutboundTransfer() and @ferryline/widget both call this endpoint for you once the burn's final step confirms (see SDK Reference).

POST /outbound-transfers

Same security property as inbound: only transferId, sourceTxHash, destinationChain, and rail are accepted — never amount/recipient, which are extracted later from the verified Iris message, exactly the same reasoning as POST /transfers. sourceDomain isn't accepted either; it's always Stellar's own CCTP domain (27 on both networks), derived server-side.

FieldTypeValidation
transferIdstringValid ULID, same isTransferId check as inbound.
sourceTxHashstring64 lowercase hex characters, no 0x prefix — the real Stellar transaction-hash format, genuinely different from inbound's 0x-prefixed EVM hash.
destinationChainstringMust resolve via cctpEvmChain for the relayer's configured network.
railstringMust be "usdc-cctp".

Responses: 201 ({ id, status: "pending", sourceChain: "stellar", sourceTxHash, destinationChain }), 409 (duplicate sourceTxHash), 429 (registration rate limit — shared with inbound: one api_keys table, one registration-time limiter across both directions, not a separate budget per direction). Requires the same Authorization: Bearer <key> as inbound.

GET /outbound-transfers/:id

The outbound mirror of GET /transfers/:id — same no-auth reasoning (an unguessable ULID-keyed read with no spend-related side effect), same response shape (id, rail, status, sourceChain, sourceTxHash, sourceDomain, destinationChain, destinationTxHash, amount, recipient, errorCode, errorDetail, version, createdAt, updatedAt), same status progression: pending → attested (message verified, amount/recipient populated) → submitting (real receiveMessage broadcast, destination tx hash recorded) → delivered.

Honest limits, not overclaimed

  • Not a guaranteed delivery-time SLA. receiveMessage remains genuinely permissionless by CCTP's own design — if the relayer is unavailable, misconfigured, or its own daily gas ceiling is exhausted, delivery doesn't happen automatically. Anyone (sender, recipient, another relayer) can still complete it manually at any time; that's the same real fallback that existed before this relayer's outbound direction shipped, not a new one — see the end-to-end walkthrough for the real manual code path.
  • One destination chain per running instance. FERRYLINE_OUTBOUND_DESTINATION_CHAIN is singular — multi-chain outbound is deferred future work, not silently supported.
  • Push-only registration. The relayer doesn't watch Stellar itself for burns; a caller who never registers a transfer (e.g. one that bypasses @ferryline/sdk/the widget entirely) gets no automatic delivery. This is a real, open question in OUTBOUND_SCOPE.md, not glossed over here.

Self-hosting with Docker Compose

The relayer ships a docker-compose.yml with two services — relayer and postgres — matching the project's self-hostable design. Everything below runs from packages/relayer/:

cp .env.example .env
# edit .env: at minimum set POSTGRES_PASSWORD, FERRYLINE_STELLAR_RPC_URL, FERRYLINE_SPONSOR_SECRET,
# and the four spend-related variables from the table below.
docker compose up -d --build

The build context is the repository root, not packages/relayer/ — the Dockerfile needs packages/core, packages/sdk, and packages/relayer copied in together — but docker-compose.yml already points at that context correctly, so you don't need to cd anywhere else. Postgres reports healthy via pg_isready before the relayer service starts, so a fresh up never races the database being ready. postgres:17-alpine and node:24-alpine are the exact base images in use.

Check it came up:

curl http://localhost:8080/healthz
{
  "status": "ok",
  "service": "ferryline-relayer",
  "version": "0.0.0",
  "uptimeSeconds": 12,
  "now": "2026-01-01T00:00:00.000Z",
  "sponsor": { "account": "G...", "nativeBalanceStroops": null, "balanceUnknown": true },
  "dailySpend": { "spentStroops": "0", "ceilingStroops": "1000000000", "ceilingReached": false }
}

balanceUnknown: true just means the sponsor account isn't funded yet on the configured network — not an error in the relayer itself.

Bootstrapping your first API key

POST /transfers (and every other integrator-facing route) checks a bearer token against the api_keys table's key_hash column. Two admin-only routes manage that table, gated by a separate FERRYLINE_ADMIN_SECRET (never the same value as any integrator key, checked by a completely separate code path — see requireAdminSecret in src/http/auth.ts), so creating one no longer needs direct database access:

curl -X POST http://localhost:8080/admin/api-keys \
  -H "Authorization: Bearer $FERRYLINE_ADMIN_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"label": "local-test"}'
# {"apiKey": "<the real, usable plaintext key — shown exactly once, save it now>", "keyHash": "...", "label": "local-test"}

The plaintext key is never stored or logged anywhere beyond that one response — there is no way to retrieve it again afterward, so copy it immediately. Revoke a key the same way, with DELETE /admin/api-keys/:keyHash; see the relayer's own README's "Admin: issuing and revoking real integrator API keys" section for the full details, including why this stays deliberately minimal (no sessions, no integrator-facing self-signup, no key-rotation UI).

With a key in place, register a real CCTP burn you've already submitted on a supported testnet source chain (ethereum-sepolia, arbitrum-sepolia, base-sepolia, or polygon-amoy — see @ferryline/sdk's CCTP_EVM_CHAINS for the current list):

curl -X POST http://localhost:8080/transfers \
  -H "Authorization: Bearer <the apiKey from the admin response above>" \
  -H "Content-Type: application/json" \
  -d '{
    "transferId": "<a real ULID — see @ferryline/core newTransferId()>",
    "sourceChain": "ethereum-sepolia",
    "sourceTxHash": "<the real 0x-prefixed 32-byte deposit_for_burn transaction hash>",
    "rail": "usdc-cctp"
  }'

A successful registration returns 201 with status: "pending". Poll GET /transfers/:id to watch it progress: pending (waiting on Iris) → attested (message verified, amount/recipient now populated) → submitting (fee-bump broadcast) → delivered.

Environment variables

src/config.ts and src/spend/config.ts load the inbound/always-on variables; outbound adds its own set, in src/config.ts's loadOutboundRelayerConfig and src/spend/outbound-config.ts, loaded only when FERRYLINE_OUTBOUND_ENABLED=true. config.ts's own doc comment draws the non-spend/spend line precisely:

Kept separate from spend/config.ts's SpendConfig, which has its own stricter "no defaults, ever" rule for money-related values — these values are allowed sensible defaults (a poll interval, a port) because getting them wrong is inconvenient, not unsafe.

Required, no default at all — the relayer refuses to start without these (config.ts):

VariablePurpose
FERRYLINE_NETWORK"mainnet" or "testnet" — which CCTP network to run against.
DATABASE_URLA Postgres connection string.
FERRYLINE_STELLAR_RPC_URLThe Soroban RPC endpoint to use.

FERRYLINE_ADMIN_SECRET is also required, no default — loaded separately in admin-config.ts rather than config.ts, since it isn't relayer-behavior config, it's the credential gating POST/DELETE /admin/api-keys (see The HTTP API below). Listed with the sponsor secrets further down, not in the table above, since that's where this README groups it too — it's genuinely more sensitive than any single integrator key, not a routine startup setting.

Required, no default — spend-related (spend/config.ts): these control real money movement, so loadSpendConfig throws rather than falling back to anything if any of them is unset, empty, whitespace-only, zero, negative, or non-numeric.

VariablePurpose
FERRYLINE_MAX_FEE_BUMP_STROOPSMax XLM stroops fee-bumped for a single mint_and_forward.
FERRYLINE_DAILY_SPEND_CEILING_STROOPSMax total confirmed XLM stroops fee-bumped per UTC calendar day, across all transfers.
FERRYLINE_MAX_TRANSFERS_PER_RECIPIENTThe real per-recipient rate limit's threshold (see above).
FERRYLINE_RECIPIENT_RATE_LIMIT_WINDOW_MSThat limit's rolling window, in milliseconds.

Optional — allowed sensible defaults, because getting these wrong is a performance/load inconvenience, not a money-safety issue (config.ts):

VariableDefaultPurpose
HOST0.0.0.0Address the HTTP server binds to.
PORT8080Port the HTTP server listens on.
FERRYLINE_POLL_INTERVAL_MS2000Initial backoff delay when a work-loop phase finds nothing to do.
FERRYLINE_POLL_MAX_INTERVAL_MS30000Ceiling that backoff delay is clamped to.
FERRYLINE_MAX_CONCURRENT_TRANSFERS20Max transfers driven concurrently, combined across both work-loop phases.
FERRYLINE_REGISTRATION_LIMIT_MAX_ATTEMPTS60The registration-time (per-API-key) spam brake's threshold — see above.
FERRYLINE_REGISTRATION_LIMIT_WINDOW_MS60000That same brake's rolling window, in milliseconds.
FERRYLINE_RELAYER_VERSION0.0.0Reported verbatim in GET /healthz's version field.

Optional, CORS (config.ts) — fail-closed, not fail-open:

VariableDefaultPurpose
FERRYLINE_CORS_ORIGINSunset → [] (no origin allowed, never *)Comma-separated list of browser origins allowed to call this relayer cross-origin — e.g. the page hosting <ferryline-widget>, if it runs on a different origin than the relayer (the normal case).

Leaving this unset is safe — server-to-server callers (curl, scripts, another backend) are never subject to CORS in the first place — but it silently blocks every browser-based caller with no server-side error to point at, since the browser itself refuses the request before it ever reaches this process. If a browser-based integrator (the widget included) calls this relayer directly, set this explicitly. Never set it to "*".

Required only when FERRYLINE_OUTBOUND_ENABLED=true (config.ts's loadOutboundRelayerConfig, non-spend):

VariableDefaultPurpose
FERRYLINE_OUTBOUND_DESTINATION_CHAINnone, requiredThe one destination EVM chain this instance serves (e.g. "ethereum-sepolia") — single-chain-per-instance in v1.
FERRYLINE_OUTBOUND_EVM_RPC_URLnone, requiredThe destination chain's own JSON-RPC endpoint.
FERRYLINE_OUTBOUND_POLL_INTERVAL_MS2000Same backoff shape as the inbound poll interval, mirrored for outbound.
FERRYLINE_OUTBOUND_POLL_MAX_INTERVAL_MS30000Ceiling for that backoff.
FERRYLINE_OUTBOUND_MAX_CONCURRENT_TRANSFERS20Same concurrency cap, mirrored for outbound.

Required only when outbound is enabled — spend-related (spend/outbound-config.ts), same "no defaults, ever" rule as the inbound spend vars, units in wei not stroops:

VariablePurpose
FERRYLINE_OUTBOUND_MAX_GAS_WEIMax wei spent on gas for a single receiveMessage call.
FERRYLINE_OUTBOUND_DAILY_GAS_CEILING_WEIMax total wei spent on gas per UTC calendar day, across all outbound transfers.
FERRYLINE_OUTBOUND_MAX_TRANSFERS_PER_RECIPIENTOutbound's own per-recipient rate limit threshold.
FERRYLINE_OUTBOUND_RECIPIENT_RATE_LIMIT_WINDOW_MSThat limit's rolling window, in milliseconds.

Deliberately not the same config type as the inbound spend vars, even though the shape is similar — the units genuinely differ (wei vs. stroops), so a Stellar fee-bump cap and an EVM gas cap can be configured independently rather than one number meaning two different things.

Three more variables matter for self-hosting but live outside all of the above:

  • FERRYLINE_SPONSOR_SECRET — required, no default, loaded in src/signer/env-secret-signer.ts. It's the sponsor account's Stellar secret seed (S...); every mint_and_forward fee-bump is signed and paid for by this account. The README is blunt about it: "Never a default, never a silently-generated throwaway key." Never commit this value.
  • FERRYLINE_OUTBOUND_SPONSOR_SECRET — the outbound mirror, required only when outbound is enabled, loaded in src/signer/env-secret-evm-signer.ts. A 0x-prefixed 32-byte hex EVM private key; every receiveMessage gas payment is signed by this account. Same "never a default, never commit this" treatment.
  • FERRYLINE_ADMIN_SECRET — required, no default, loaded in src/admin-config.ts. Gates POST/DELETE /admin/api-keys — see The HTTP API below. Genuinely more sensitive than any single integrator key it can create: anyone holding it can mint unlimited spend-capable keys, all sharing this relayer's one sponsor account and daily spend ceiling. Must be a real, separately-generated value — never reused from either sponsor secret above or from any integrator key. Never commit this value.
  • POSTGRES_PASSWORD (Docker Compose only, required) and POSTGRES_HOST_PORT (Docker Compose only, default 5432) — read directly by docker-compose.yml, not by the relayer's own Node process. POSTGRES_PASSWORD is shared between the postgres and relayer services in the compose file's DATABASE_URL interpolation — don't set it independently in two places.

The HTTP API

Five routes total, registered in src/http/app.ts. Every route's request/response shape is a zod schema, wired as both Fastify's validator and serializer compiler — so a malformed request is rejected with 400 before the handler ever touches the database or a chain call, and the app's global error handler catches any zod validation failure it misses:

app.setErrorHandler((error, request, reply) => {
  if (hasZodFastifySchemaValidationErrors(error)) {
    reply.code(400).send({
      error: "request validation failed",
      details: error.validation.map((v) => ({
        path: v.instancePath || v.schemaPath,
        message: v.message,
      })),
    });
    return;
  }
  request.log.error(error);
  reply.code(500).send({ error: "internal error" });
});

Anything else unhandled becomes a 500 with { "error": "internal error" }.

POST /transfers

Registers a transfer. Requires Authorization: Bearer <key> — see MVP auth below.

Request body:

FieldTypeValidation
transferIdstringMust be a valid ULID (26 Crockford-base32 characters) — @ferryline/core's isTransferId.
sourceChainstringMust resolve via @ferryline/sdk's cctpEvmChain for the relayer's own configured network — a testnet relayer rejects mainnet chain slugs and vice versa.
sourceTxHashstring0x-prefixed 32-byte hex (/^0x[0-9a-fA-F]{64}$/) — the EVM deposit-for-burn transaction hash.
railstringMust be "usdc-cctp" — the only entry in SUPPORTED_RAILS.

Responses:

StatusBodyWhen
201{ id: string, status: "pending", sourceChain: string, sourceTxHash: string }Registered.
409{ error: string } ("a transfer for <chain>:<hash> is already registered")Same sourceChain/sourceTxHash pair already exists.
429{ error: string } (names the attempt count in the current window)Registration-time, per-API-key rate limit exceeded (see above — not a per-recipient signal).

Two more status codes exist in practice but aren't part of this route's own zod response schema object: 401 (missing/malformed/invalid bearer token, from the requireApiKey preHandler) and 400 (schema validation failure, from the app-level error handler above).

POST /admin/api-keys

Creates a real, usable integrator API key. Requires Authorization: Bearer <admin secret> — checked by requireAdminSecret, a completely separate code path from requireApiKey above (see MVP auth below). An integrator's own API key never authenticates this route, and the admin secret never authenticates POST /transfers or any other integrator-facing route — there is no shared code between the two checks.

Request body:

FieldTypeValidation
labelstring1-200 characters, operator-assigned.

Responses:

StatusBodyWhen
201{ apiKey: string, keyHash: string, label: string }Created. apiKey is the real plaintext, shown exactly once.
401{ error: string }Missing, malformed, or wrong admin secret.
400{ error: string }Schema validation failure (empty or overlong label).

apiKey is generated with crypto.randomBytes(32), hex-encoded. Only its SHA-256 hash (via the same hashApiKey function requireApiKey's own lookup uses) is ever persisted — the plaintext is never logged or stored anywhere beyond this one response, so there is no way to retrieve it again afterward.

DELETE /admin/api-keys/:keyHash

Revokes an integrator API key. Requires Authorization: Bearer <admin secret>, same requireAdminSecret check as above.

FieldTypeValidation
keyHashstring64-character lowercase hex (a SHA-256 hash), a URL param.

Responses:

StatusBodyWhen
204(empty)Revoked, or keyHash didn't match any row — idempotent either way.
401{ error: string }Missing, malformed, or wrong admin secret.
400{ error: string }keyHash isn't a well-formed 64-character hex string.

Takes effect immediately: the very next request bearing that key's plaintext is rejected with 401 by requireApiKey, which already reads revoked_at IS NULL on every lookup.

GET /transfers/:id

Status lookup. No auth required. The route's own doc comment explains why plainly:

No auth required: this is a read of a ULID-keyed row (unguessable) and carries no spend-related side effect, unlike POST /transfers which spends the sponsor's gas budget by admitting work.

Response 200:

FieldType
idstring
railstring
status"pending" | "attested" | "submitting" | "delivered" | "failed"
sourceChainstring
sourceTxHashstring
sourceDomainnumber
destinationChainstring
destinationTxHashstring | null
amountstring | null — null until the row reaches attested
recipientstring | null — null until the row reaches attested
errorCodestring | null (e.g. "RECIPIENT_RATE_LIMITED")
errorDetailstring | null
versionnumber
createdAtstring (ISO-8601)
updatedAtstring (ISO-8601)

Response 404: { error: string } ("no transfer with id <id>") if no row matches.

GET /healthz

No auth required — this is a monitoring endpoint, not a data endpoint. Its own doc comment notes the balance read is deliberately live, not cached, on every call: "matching the 'no digging through logs' requirement over a 'fast endpoint' optimization — this route is for humans and monitoring, not a high-frequency hot path."

Response 200:

FieldTypeNotes
status"ok"
service"ferryline-relayer"
versionstringFrom FERRYLINE_RELAYER_VERSION.
uptimeSecondsnumber
nowstring (ISO-8601)
sponsor.accountstringThe sponsor's Stellar public key, derived from FERRYLINE_SPONSOR_SECRET.
sponsor.nativeBalanceStroopsstring | nullStroops, as a string (can exceed Number.MAX_SAFE_INTEGER); null if unknown.
sponsor.balanceUnknownbooleantrue if the account doesn't exist yet, isn't funded, or the RPC call failed.
dailySpend.spentStroopsstringActual confirmed spend so far today (UTC).
dailySpend.ceilingStroopsstringEchoes FERRYLINE_DAILY_SPEND_CEILING_STROOPS.
dailySpend.ceilingReachedbooleanspentStroops >= ceilingStroops.

When outbound is configured, the response gains an optional outbound object — omitted entirely (not null) if this process doesn't have the outbound direction running:

FieldTypeNotes
outbound.destinationChainstringEchoes FERRYLINE_OUTBOUND_DESTINATION_CHAIN.
outbound.sponsor.addressstringThe EVM sponsor's address, derived from FERRYLINE_OUTBOUND_SPONSOR_SECRET.
outbound.sponsor.nativeBalanceWeistring | nullWei, as a string; null if unknown.
outbound.sponsor.balanceUnknownbooleantrue if the RPC call failed.
outbound.dailySpend.spentWeistringActual confirmed gas spend so far today (UTC).
outbound.dailySpend.ceilingWeistringEchoes FERRYLINE_OUTBOUND_DAILY_GAS_CEILING_WEI.
outbound.dailySpend.ceilingReachedbooleanspentWei >= ceilingWei.

CORS

Real, current, and fail-closed by default — see the environment variables table above for FERRYLINE_CORS_ORIGINS. Registered before any route in src/http/app.ts via @fastify/cors. Worth stating plainly since this used to be a real gap this project found and fixed: leaving it unset doesn't fail open — it produces an empty allow-list, so every browser-based caller is silently blocked by the browser itself (not a server error you can find in the relayer's own logs) until you set it. Server-to-server callers are unaffected either way.

Operational caveats

These are real limitations of the current implementation, not softened for the docs:

  • No framework-level rate limiting on the unauthenticated GET routes. GET /transfers/:id, GET /outbound-transfers/:id, and GET /healthz have no rate limiting registered at the Fastify level. The one rate limit that exists (registrationLimiter) is checked only inside the two POST handlers, after auth — it does nothing for any GET route. Each read is individually cheap (an indexed lookup or a live balance read), but nothing here stops a caller from hitting one as fast as they like; if that matters for your deployment, put a rate limiter in front at the proxy/gateway layer.

  • The auth system is deliberately MVP, and shared across both directions. src/http/auth.ts says so directly:

    MVP bearer-token allow-list. Per the phase-3 sign-off, this is deliberately NOT a full auth system: no sessions, no scopes, no key rotation UI — just enough to gate who the sponsor account pays gas for.

    Concretely: keys are stored as a SHA-256 hash in the api_keys table (never plaintext). An admin-only POST/DELETE /admin/api-keys pair now creates and revokes them (gated by a separate, more sensitive FERRYLINE_ADMIN_SECRET) — see Bootstrapping your first API key above. There's still no key rotation flow (revoking and creating a fresh key are two separate calls, not one atomic operation), no listing endpoint, and no scopes. One api_keys table and one registration-time limiter cover both POST /transfers and POST /outbound-transfers — a key rate-limited on one direction is rate-limited on both, by design, not because the two directions were never distinguished. If you need real multi-tenant auth, session scoping, key rotation, or integrator-facing self-service, this isn't that yet — treat it as exactly what it says it is, a gate on who the sponsor accounts pay gas for, now provisionable without direct database access.

Where to go next

  • Core Concepts — the two rails, the transfer lifecycle, and why CCTP's delivery gap exists in the first place.
  • SDK Reference — registerOutboundTransfer()/isFinalStep(), the SDK-side calls that register an outbound transfer with this relayer automatically.
  • Router — the Soroban contract a Stellar-source burn calls into.
  • Security & Verification — the project-wide findings that shaped decisions like the ones documented on this page.

A note on what this page couldn't verify

This page is grounded in packages/relayer's own README, OUTBOUND_SCOPE.md, OUTBOUND_THREAT_MODEL.md, Docker Compose/Dockerfile setup, .env.example, and the source files that define its config and HTTP layer for both directions (config.ts, admin-config.ts, spend/config.ts, spend/outbound-config.ts, http/app.ts, http/routes/*.ts (including admin-api-keys.ts), http/schemas.ts, http/outbound-schemas.ts, http/auth.ts, spend/registration-limit.ts, signer/env-secret-evm-signer.ts) — all read directly for this page (this page was rewritten once already, against real, current origin/main, after an earlier draft was found to have been written against a commit that predated the outbound direction and CORS support entirely — see the earlier draft's own now-corrected claims if you're diffing history). Two things worth flagging rather than glossing over: first, the exact Fastify request-lifecycle ordering between schema validation and the requireApiKey preHandler is described by the route's own source comment (http/routes/register-transfer.ts) rather than independently verified against Fastify's lifecycle docs here. Second, this page did not run the Docker Compose stack or hit a live endpoint in the course of writing it — every request/response example here is copied verbatim from the README or derived directly from the zod schemas in source, not captured from a running instance.

On this page