Onderhaus (say it "on the house"): cross-chain USDC payments between Arc and Stellar where the recipient needs no gas. The gas is on the house.
A recipient receives native USDC without ever holding the destination chain's gas token. The
sender burns USDC through CCTP V2 as usual, then hands the burn hash to Onderhaus, which
submits the completion and pays the fee from its own funded account. The completion calls
come from the @asctp/parabola SDK.
Both directions are supported. Arc to Stellar settles on Stellar and costs XLM. Stellar to Arc settles on Arc, where gas is paid in native USDC, and is funded from a second account.
This is Phase 1 of three, and it is an early proof that runs rather than a product with users. What exists today is a single authenticated service that completes transfers in both directions, and both directions have been exercised end to end on testnet. Nothing has been run against a funded account on mainnet, and there is no hosted instance and no demo page. See Scope for the precise boundary.
CCTP moves native USDC by burning it on a source chain and minting it on a destination
chain after Circle attests the burn. On Stellar the final step is not a plain mint. It is a
call to mint_and_forward on Circle's on-chain CctpForwarder contract, which mints to
itself and then forwards the USDC to the real recipient. That contract already exists and is
deployed.
The problem is that every Stellar transaction costs a fee paid in XLM, so whoever submits
mint_and_forward must hold XLM. Circle runs a hosted relayer that covers this on EVM chains
and Solana, but not on Stellar. This service is the missing relayer for that rail.
The property that makes it work is that the submitting account has nothing to do with who gets paid. The recipient is fixed inside the burn message's hook data at burn time, not passed as an argument. So one funded XLM account can pay the fee for any number of recipients, and none of them need XLM, or a wallet, or anything else on Stellar beyond a USDC trustline.
A Stellar account must exist on-ledger and hold a USDC trustline before it can receive USDC.
The trustline is the half this service cannot set up for a recipient, because change_trust
is signed by the account owner. Activation is the easier half, since create_account is
signed only by the funder, but this service performs neither and treats both as preconditions.
A transfer to an account without a trustline cannot land, so the service checks both
conditions before spending anything and refuses with a clear error if either is missing.
That requirement is one-sided, and where it applies follows from where the money settles. An
Arc recipient needs no setup, because any address on an EVM chain can hold USDC. So on the
Stellar to Arc direction there is nothing to verify, and the recipient field is recorded on
the ledger rather than used as a gate on spending.
Requires Node 20 or later and pnpm.
pnpm install
cp .env.example .env
# Set STELLAR_SECRET_KEY to a funded Stellar account, and leave NETWORK=testnet to start.
# Set ARC_SECRET_KEY as well if you want the Stellar to Arc direction.
pnpm key:create -- --label "my-first-key" --daily-cap 100 --rate-limit 60
# Prints the API key once. Only its hash is stored, so save it now.
pnpm devThe service refuses to complete anything until it has successfully read the balance of the account that pays for a direction and found it above that direction's floor. That is deliberate, so a misconfigured or unreachable Horizon ends in a refusal rather than in spending from an account whose balance was never confirmed.
The service halts until each paying account is funded, so a fresh checkout needs funded
accounts before /readyz reports ready.
- The Stellar account named by
STELLAR_SECRET_KEYneeds testnet XLM. Stellar's Friendbot funds a testnet account:curl "https://friendbot.stellar.org/?addr=<G...public key>". - The Arc account named by
ARC_SECRET_KEY, only for the reverse direction, needs testnet Arc gas, which is native USDC. There is no Friendbot equivalent for Arc, so obtain testnet funds for that account from the Arc testnet faucet or provider you use.
Only Stellar-destination transfers need a prepared recipient; an Arc address needs nothing.
A Stellar recipient must exist on-ledger and hold a USDC trustline, both signed for by the
recipient, not by this service. For a test account, fund it with Friendbot as above to
activate it, then have that account add a USDC trustline with a change_trust operation for
the testnet USDC asset. Until both are done, /v1/complete refuses that recipient with 422
recipient_not_ready and spends nothing.
Check whether it considers itself able to work:
curl -s localhost:3000/healthz # {"status":"ok"}
curl -s localhost:3000/readyz # see the shape below, or 503 when nothing works
curl -s localhost:3000/v1/status -H "Authorization: Bearer $ONDERHAUS_KEY"/healthz is liveness and stays 200 whenever the process is running. /readyz is readiness,
and it reports each direction separately because each is paid for by a different account. It
answers ready when every configured direction is funded, degraded when some are and some
are not, and 503 halted only when the service can serve nothing at all. Both probes answer
without a key, and neither returns a balance figure, which stays behind the key on
/v1/status.
/readyz returns the network the instance serves and a directions object naming each
direction and whether it is ready, so a client can tell up front which network and which
legs it can use without provoking a 400:
{
"status": "degraded",
"network": "testnet",
"directions": { "arc->stellar": "ready", "stellar->arc": "halted" }
}The 503 body carries the same status, network, and directions fields plus a reason
string.
Onderhaus only finishes a transfer. The integrating app produces the input, the burnTxHash,
by burning through CCTP with Parabola first. The one requirement is to leave the completion
open: call Parabola's transfer() without a destinationSigner, so the SDK burns on the
source chain, stops, and returns status: "pending" with the burnTxHash to hand on.
import { transfer } from "@asctp/parabola";
// signer is a source-chain signer built the way Parabola's own README shows: a StellarSigner
// from a Keypair when burning on Stellar, an ArcSigner wrapping a viem WalletClient on Arc.
const result = await transfer({
from: "arc",
to: "stellar",
amount: "1.0",
recipient: "GBZX...the Stellar recipient...",
speed: "standard",
signer,
network: "testnet",
// No options.destinationSigner, so the SDK leaves the mint for Onderhaus.
});
// result.status === "pending", result.burnTxHash is what you send to /v1/complete.Pass result.burnTxHash and the same recipient to /v1/complete. Signer construction is
Parabola's surface rather than this service's, so see the Parabola README for it.
curl -s localhost:3000/v1/complete \
-H "Authorization: Bearer $ONDERHAUS_KEY" \
-H "content-type: application/json" \
-d '{
"burnTxHash": "0x...",
"from": "arc",
"to": "stellar",
"recipient": "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI",
"network": "testnet"
}'The recipient field is required. The app that initiated the burn already holds the address it
encoded, so it passes that back. The value is advisory. The service reads the true recipient out
of the burn message and pays that address regardless, so the field cannot redirect funds. It is
used only to confirm the destination can receive before spending. If it disagrees with the
address in the burn, the effect is a wasted or refused attempt, never a payment to the wrong
account: on Arc to Stellar a stale recipient can cause a false recipient_not_ready, and on
Stellar to Arc the field only lands on the ledger and does not gate spending at all.
A successful completion returns the destination transaction hash:
{ "mintTxHash": "...", "attestationHash": "...", "replayed": false }If the same burnTxHash is submitted again by the same key on the same network, the service
returns the recorded result with replayed: true rather than submitting a second time.
Idempotency is keyed on the API key, the burn hash, and the network together, so the same burn
presented under a different key is not deduplicated against the first, which matters only for a
caller fronting several keys. A burn whose earlier attempt failed is not treated as done; it
can be retried and will be submitted again.
Stellar to Arc takes the same shape with the formats swapped. The burn happens on Stellar, so
the hash is a bare 32 byte transaction hash with no 0x prefix, and the recipient is an Arc
address.
curl -s localhost:3000/v1/complete \
-H "Authorization: Bearer $ONDERHAUS_KEY" \
-H "content-type: application/json" \
-d '{
"burnTxHash": "...",
"from": "stellar",
"to": "arc",
"recipient": "0x...",
"network": "testnet"
}'This direction needs ARC_SECRET_KEY. Without it the request is refused with 501 rather
than being attempted, so a deployment that only ever meant to spend XLM never needs a second
hot key.
| Status | Code | Meaning |
|---|---|---|
| 200 | Completed. mintTxHash is the settling transaction, on Stellar or on Arc. |
|
| 400 | validation_error |
Malformed request, or a pair of chains that is not a direction this service knows. |
| 400 | network_mismatch |
The request named a network this service is not configured for. |
| 401 | unauthorized |
Missing, unknown, or disabled API key. |
| 409 | attestation_pending |
Circle has not attested the burn yet. Retry the same request. |
| 409 | mint_in_progress |
Another request for this burn is being submitted right now. Retry shortly. |
| 422 | recipient_not_ready |
The Stellar recipient account does not exist, or has no USDC trustline. |
| 429 | rate_limited |
The key's rate limit or the caller's per-address limit was exceeded. |
| 429 | spend_cap_exceeded |
Per-key daily completion cap reached. Resets at 00:00 UTC. |
| 501 | direction_unavailable |
The direction is real, but this deployment holds no account to pay for it. Set ARC_SECRET_KEY to enable Stellar to Arc. |
| 502 | upstream_error |
A failure outside this service. Read the message before retrying. |
| 503 | insufficient_service_balance |
The account that pays for this direction is below its floor, or its balance is unconfirmed. |
A 502 caused by a submission timeout is the one case where the outcome is genuinely unknown. The transaction was broadcast and may still land, so the caller should check it before retrying. Retrying is not dangerous, because CCTP rejects a message that has already been used, but it costs another fee to find that out.
Every error shares one envelope. The body is an error object carrying the code from the
table, a human message, and a retryable boolean a client can branch on directly instead
of maintaining its own list of which codes are safe to repeat:
{ "error": { "code": "attestation_pending", "message": "Circle has not attested burn ... yet. Retry the same request in a few seconds.", "retryable": true } }On the submission-timeout 502, and only there, the object also carries a txHash field with
the broadcast transaction's hash, so a caller can check that transaction without parsing it
out of the message:
{ "error": { "code": "upstream_error", "message": "The completion transaction was broadcast as 0x... but confirmation did not arrive in time. It may still land; check that hash before retrying.", "retryable": true, "txHash": "0x..." } }The API key is a bearer secret that can spend the service's funds, so the primary integration path is server to server, with the key held on a backend and never shipped to a browser. A frontend that needs completions should call its own backend, which holds the key and calls Onderhaus. There is no scoped or publishable key today, so a key placed in client code is a spendable secret exposed to every visitor.
For the browser cases that are safe, such as reading the probes, the service returns
Access-Control-Allow-Origin: *. Because the key travels in the Authorization header rather
than a cookie, a cross-origin request is uncredentialed in CORS terms, so the wildcard exposes
nothing a caller could not already reach, and no integrator has to be added to an allow-list by
hand. A browser request blocked for any other reason surfaces as a generic TypeError: Failed to fetch, which a client cannot tell apart from the host being down.
Built:
- An authenticated HTTP service that completes CCTP transfers in both directions
- Pre-spend verification that a Stellar recipient exists and holds a USDC trustline
- Per-direction balance monitoring on whichever account pays for a leg, with an independent low-balance halt on each
- Per-key API keys, rate limiting, daily spend caps, and a metered usage ledger that records the direction each row was for
- Idempotent completion keyed on the burn hash, so a retry does not resubmit
Not built:
- A hosted instance and a demo page, which is Phase 3.
- A mainnet run. Everything so far has been on testnet.
- Any on-chain fee mechanism. See the fee model below.
- Batch submission. Each completion is one request.
Every completion spends real money from whichever service account pays for that direction, XLM from the Stellar-funded account and native USDC from the Arc-funded one, so access is metered rather than open. Each integrating app gets an API key carrying a per-minute rate limit and an optional daily cap, and every completion is written to a local ledger holding the API key, the burn hash, the direction, the recipient, and the outcome.
Payment is off-band. Usage is invoiced on a cycle you set, and nothing enforces payment
on-chain, so the model rests on the integrator paying. That ledger is both the billing record
and the idempotency guard, since it is unique on the API key, the burn hash, and the network
together. The direction is deliberately not part of that key, which is safe because the two
hash formats cannot collide: a Stellar hash is bare and an Arc one carries the 0x prefix,
so the same text is never valid for both legs.
The trustless alternative is a USDC fee taken on the source chain at burn time, or skimmed by a forwarder contract before forwarding. Both need on-chain code this service does not ship, and both are the path once there is volume worth the audit.
This service custodies hot, funded keys on two chains and spends real money on request. Running it means accepting that. Its answer to that risk is a single rule that the whole request path is built around. The engine works hard to say no before it ever says pay, and every control below serves that order.
- API keys are required on every spending endpoint. There is no unauthenticated completion path. Only the two probes are open, and they reveal nothing beyond liveness and whether the service is currently willing to work.
- Only hashes of API keys are stored. A key that is lost cannot be recovered and must be replaced.
- Per-key rate limits and daily caps bound what any single caller can cause the service to spend.
- A per-address rate limit bounds everything else, including requests presenting no key and requests carrying a body the schema rejects. Both limiters run before the body is validated, because a limiter that only ever sees well-formed authenticated traffic can be bypassed by sending neither. The probes are exempt so that health checking cannot take the service out of rotation during the incident it exists to detect.
- The service keys never leave the environment, are never logged, and are never written
to the database. Log output redacts the
Authorizationheader so a key cannot leak into a log file through a request. - The reverse direction is off unless configured. Stellar to Arc needs
ARC_SECRET_KEY, and without it the deployment refuses that direction with 501 and polls no Arc balance, so a service that only ever meant to spend XLM never holds a second hot key. - A per-direction low-balance halt stops a leg when the account paying for it falls below its configured floor, without stopping the other leg. The halt fails closed: until the first successful balance read for that leg, it reports itself halted and refuses to complete anything.
- Mainnet is opt-in. The service defaults to testnet, which differs from the SDK's own default, and it refuses any request naming a network it is not configured for.
The recipient field deserves one explicit note. The caller supplies it, and the service uses it only to decide whether a mint is worth attempting. It cannot be used to redirect funds, because the completion pays whichever address is encoded in the burn message. A wrong recipient can therefore cause a refused transfer or a wasted submission, but never a payment to the wrong account. On the Stellar to Arc direction it does not even gate spending, since there is nothing to verify about an Arc address.
See .env.example for the full list with notes. The values that matter most:
| Variable | Purpose |
|---|---|
STELLAR_SECRET_KEY |
The funded account that pays the XLM fee. Required. |
ARC_SECRET_KEY |
The funded Arc account that pays that chain's gas in native USDC. Optional. Without it, Stellar to Arc is refused with 501 and no Arc balance is polled. |
NETWORK |
testnet or mainnet. Defaults to testnet. |
STELLAR_RPC_URL |
Soroban RPC override. The mainnet default is rate limited and is not meant for production traffic. |
ARC_RPC_URL |
Arc RPC override, for the same reason. |
DATABASE_PATH |
Where the key table and ledger live. |
XLM_LOW_BALANCE_FLOOR |
Halt threshold for the Stellar-funding account. |
USDC_LOW_BALANCE_FLOOR |
Halt threshold for the Arc-funding account, in native USDC. |
IP_RATE_LIMIT_PER_MINUTE |
Per-address ceiling covering traffic that presents no key or a malformed body. Defaults to 600. |
ATTESTATION_POLL_TIMEOUT_MS |
How long one request waits for Circle before answering 409. Defaults to 30 seconds, well below the SDK's 5 minutes, so a request does not hold a connection open for minutes. |
TRUST_PROXY |
Set true only behind a proxy you control, so the per-address limit sees the real client address rather than the proxy's. |
One detail about the Arc floor is worth stating plainly, because it is easy to get wrong.
Arc pays gas in USDC as the chain's native token, and that balance is reported at 18
decimals. The ERC-20 USDC that CCTP mints and burns is the same asset seen through a
6-decimal contract, not a second token: on testnet a native balance of 18.99551755 reads as
18.995517 through the contract, the native figure truncated to six places, and the two move
together through a burn and a settlement. So USDC_LOW_BALANCE_FLOOR is measured against
the native balance, and a floor written in the wrong scale is wrong by a factor of a
trillion without failing loudly.
pnpm typecheck # tsc --noEmit
pnpm test # vitest, 85 tests, no network access
pnpm build # tsup, emits ESM to dist/
pnpm key:create # provision an API key
pnpm key:list # list keys and today's usageTests inject fakes for the two SDK calls that would otherwise reach the network, so the
suite runs offline and never spends anything. pnpm test currently passes with 85 tests
across 4 files.
Both directions have been completed against a live testnet burn produced outside the service, and each settlement was then verified independently of it.
Arc to Stellar. Burn
0x24d19d7515f72359fa7bb8b066674b1bb7de4c32a0a424d59717641b03f80783 was submitted and
settled as Stellar transaction
c60dafc3e40bfb65fd72499ce649affa7d030650d66c93fe616b3630de1637e8 in ledger 5056172. The
recipient held 1.0000000 USDC afterwards, with their XLM balance untouched by the mint. The
service account paid 186890 stroops, the ledger row recorded one attempt, and replaying the
same burn returned replayed: true without a second submission.
Stellar to Arc. Burn
a62a448a3b5fb826644f26717c8a2e295ab772af47f337715dfb20ab3aaebb10 was produced by a
transfer call with no destinationSigner, which leaves the SDK returning a pending
transfer and completion to whoever holds the hash. The service completed it in 3.783 seconds
as Arc transaction
0xed4f8572b9c3c598235edc45844e1e20bba0cdc2a0dc940ad22c3f1ba4cb1081 in block 65830403,
status success, from the service's own Arc key. The receipt was read independently: 186731
gas at 0.000000025 USDC per gas, a fee of 0.004668275 USDC. The recipient's balance read
exactly 0.5 USDC afterwards and the service's native balance fell by exactly the fee.
Replaying the same burn returned replayed: true without a second submission.
No mainnet transfer has been run in either direction.
MIT