For the complete documentation index, see llms.txt. This page is also available as Markdown.

Configure

Every prop the widget accepts — shared config, per-component props, events, wallets, coverage and compliance.

All five components extend one shared config object, StableGenieConfig. You can pass it as props on a single widget, or once on StableGenieProvider for several. Component-specific props sit on top.

Shared configuration

Every widget accepts these.

Prop
Type
Default
Notes

apiBase

string

/api/widget/v1

The same-origin keyless proxy you deploy. See The Keyless Proxy.

apiKey

string

—

Not needed against the keyless proxy. Attached only to a same-origin apiBase, or to an absolute one matching trustedApiOrigin.

trustedApiOrigin

string

—

The single absolute origin permitted to receive apiKey, e.g. https://pay.example.com. Omit and no absolute origin is trusted.

provider

Eip1193Provider

EIP-6963 discovery

Inject your app's wallet provider so the user does not connect twice.

rpcOverrides

Record<number, string>

registry / bundled

Per-chain RPC used for settlement verification and balance reads only.

theme

Partial<StableGenieTheme>

light

See Theming.

title

string

per component

Card heading.

onQuote

(plan: Plan) => void

—

Fires when a plan is priced.

onSuccess

(info: SuccessInfo) => void

—

Fires only on a receipt-confirmed settlement. See Events.

onError

(error: Error) => void

—

Carries the unsanitized error for your logging.

onTransaction

(entry: SanitizedTxEntry) => void

—

Display-safe record of the settling transaction.

Sharing config across widgets

import { StableGenieProvider, StableGenieExchange, StableGeniePay } from "@glacislabs/stablegenie-zerodelta";

<StableGenieProvider apiBase="/api/widget/v1" provider={provider} theme={{ mode: "dark" }}>
  <StableGenieExchange />
  <StableGeniePay recipient="0x…" toChainId={42161} toAsset="USDC" amount="250.00" />
</StableGenieProvider>

Props win over context, and theme is the one field that merges. A widget's own props replace the provider's values, except theme, which is shallow-merged ({ ...context.theme, ...props.theme }). So a provider can set mode: "dark" globally and one widget can override accent alone.

Three axes, not one

StableGenieExchange is configured along three independent axes. They are easy to confuse because two of them are commonly called "variant" elsewhere.

Axis
Prop
Values
What it decides

Market

mode

exchange · onramp · offramp

What the card trades against

Universe

product

stable · rwa

Which assets are selectable

Layout

variant

compact · wide · pill

How the card looks

Only mode: "exchange" is live. onramp and offramp render an explicit coming-soon panel — the widget never quotes a rail it cannot execute.

product: "stable"

product: "rwa"

mode: "exchange"

✅ live

✅ live

mode: "onramp"

○ coming soon

○ coming soon

mode: "offramp"

○ coming soon

○ coming soon

StableGenieRwaExchange is the same component with product="rwa" pre-set — same card, same pickers, same flow, same engine. Only the catalogue and the pair rule differ.

The three layouts

  • compact — one card: a You pay pane and a You receive pane with a swap button on the seam between them, a Compliance and a Quote button, and a rate line that opens onto the network fee and the estimated time.

  • wide — the From / To tiles and the amount on a 360 px card, beside a 280 px You receive card that carries the received amount, the rate (open), and the Compliance and Quote rows with their verdicts. It folds to one column below a 660 px container.

  • pill — a single button, Exchange with ZeroDelta · 100 USDC, that opens the compact card in a modal or a drawer. See The pill.

Each pane prints a dollar line under its amount when the gateway's price snapshot covers the asset (read through your proxy's prices path — see The Keyless Proxy). The dollar figure is display only: it is never an input to a quote or a signature, and an asset the snapshot does not price simply shows no dollar line.

From v4.8.0 amounts read the way the ZeroDelta app prints them. The amount field groups with thousands separators while it is typed (1,234,567.5), and the received amount is shown to seven significant digits, grouped, with the exact figure as a tooltip when the label is rounded. A comma is only ever the separator: the value the card holds, and reports through onInputChange, is the bare number (1234567.5), and a comma typed by hand is ignored rather than read as a decimal point. A long amount shrinks to fit its slot instead of being cut off.

Component props

StableGenieExchange / StableGenieRwaExchange

Prop
Type
Default

mode

"exchange" | "onramp" | "offramp"

"exchange"

product

"stable" | "rwa"

"stable"

variant

"compact" | "wide" | "pill"

"compact"

openAs

"modal" | "drawer"

"modal" — pill only

heightPx

number

fit-content

assets

SupportedAsset[]

the product's full universe

defaultFromChainId / defaultToChainId

number

—

defaultFromAsset / defaultToAsset

SupportedAsset

—

defaultAmount

string

—

onInputChange

(input: ExchangeInput) => void

— see Events

recipient

string

the payer

fiatCurrency

"USD" | "EUR" | "GBP" | "CHF"

—

StableGenieRwaExchange takes the same props minus product.

StableGeniePay — exact-receive

The recipient receives exactly amount of toAsset on toChainId; the payer funds it from whatever they hold.

Prop
Type
Default

recipient

string

—

required

toChainId

number

—

required

toAsset

SupportedAsset

—

required

amount

string

—

required, decimal string

fundingAssets

SupportedAsset[]

the seven executable stables

narrows what may fund the payment

heightPx

number

fit-content

variant

"pill"

full card

a Pay with ZeroDelta button — see The pill

openAs

"modal" | "drawer"

"modal"

pill only

moneyDisplay

string

amount and toAsset

pill face, e.g. "$99.00"

defaultExpanded

boolean

false

pill only — start with the card already open

settlement

"stablecoin" | "bank"

"stablecoin"

how you are settled, never a payer choice

bankAccount

BankPayoutAccount

—

display-only; a masked tail, never an account number

The card shows the payer one You pay pane — the funding token they picked, its amount and their balance — a Compliance row with the verdict, the network fee and the estimated time, and the pay button. Before a wallet is connected it asks for one and shows nothing else.

recipient has no default and never falls back to the payer. The widget will not quote without one. This is deliberate: a deposit widget that silently paid the payer's own wallet would be a silent loss of funds.

settlement: "bank" is not shipped. It is a roadmap rail, so the card parks with a coming-soon panel rather than quoting a settlement the engine cannot perform. A "bank" pill keeps the ZeroDelta Link island (below), the one surface that renders that panel.

StableGenieSwap — exact-in

A plain card with two selects. StableGenieExchange is the richer surface; use StableGenieSwap when you want the smaller one.

Prop
Type
Default

defaultFromChainId

number

42161

defaultFromAsset

SupportedAsset

"USDT"

defaultToChainId

number

8453

defaultToAsset

SupportedAsset

"USDC"

defaultAmount

string

""

recipient

string

the payer

variant

"pill"

full card

The express-checkout island: a collapsed button that morphs into a one-click card. variant="pill" on StableGenieSwap renders it, and so does a StableGeniePay pill with settlement: "bank"; you can also mount it directly. StableGenieExchange and every other StableGeniePay pill use the pill launcher instead.

Prop
Type
Notes

kind

"pay" | "swap" | "exchange"

required — which shape the card takes

defaultExpanded

boolean

ramp modes force this on

moneyDisplay

string

the collapsed pill face

onIdentitySave

(id: LinkIdentity) => void

host persistence seam

onDone

() => void

payer dismissed a settled receipt

It also accepts the pay-shaped props (recipient, toChainId, toAsset, amount, fundingAssets, settlement, bankAccount) and the exchange-shaped ones (mode, product, assets, the default* fields).

The saved identity is opt-in, client-side and namespaced per wallet. A returning payer's preferences live in localStorage under sg:link:v1:<wallet address>, hold crypto preferences only, and are forgettable from the card's own menu. Nothing is sent anywhere.

The pill

variant="pill" on StableGenieExchange (and StableGenieRwaExchange) and on StableGeniePay renders one button — Exchange with ZeroDelta · 100 USDC, or Pay with ZeroDelta · $20.00 — that opens the ordinary compact card over your page.

openAs

Opens as

"modal"

the card centred over a dimmed page (default)

"drawer"

a full-height panel docked to the right edge, the pay button pinned to its foot

  • Nothing runs behind a closed pill. The card — and with it every catalogue read, quote and balance read — mounts only when the payer opens it. It is the same card with the same guarantees, not a second implementation.

  • It closes the way a dialog should. Escape, the close button in the card's head, or a click on the dimmed page close it, and keyboard focus returns to the pill.

  • The dim layer is position: fixed. It covers the viewport — or the nearest ancestor that establishes a containing block for fixed elements (one with a transform, filter or contain: paint). Give a region of your page one of those if you want the dialog confined to it; the widget playground does exactly that with its preview frame.

The pill's face uses defaultAmount and defaultFromAsset (exchange) or moneyDisplay, falling back to amount and toAsset (pay).

Wallets

Pass your app's provider and the widget reuses the existing connection silently:

With no provider, the widget discovers one itself: EIP-6963 announcement first (300 ms window, first wallet announced), then legacy window.ethereum, then an install prompt. It follows accountsChanged and chainChanged, and adopts a provider injected mid-session.

There is no wagmi or viem runtime dependency — the package has zero runtime dependencies.

Network switching on Plume. The widget bundles wallet_addEthereumChain metadata for eight of the nine source chains. Plume (98866) has none, so a wallet that does not already know Plume must add it manually. Every other chain can be added from the widget.

Events

Callback
Payload
Fires when

onQuote

Plan

a route has been priced

onSuccess

SuccessInfo { hash, verdict, route }

the settling transaction is confirmed by receipt

onError

Error

any failure; carries the original, unsanitized message

onTransaction

SanitizedTxEntry

a settling transaction was submitted

onInputChange

ExchangeInput

the exchange card's pair or amount changed (v4.8.0)

onSuccess means the money moved. It fires only when the transaction is mined with a success receipt (verdict.status === "confirmed"). A broadcast-but-unmined transaction resolves as unconfirmed and a failed one as reverted; neither fires onSuccess. Treat the callback as settlement, not as submission — because that is what it is.

onTransaction receives a whitelisted, display-safe record: no API keys, no calldata, no wallet secrets, every string length-bounded. It records the settling transaction only — never the ERC-20 approve. Errors thrown from your callback never break the payment flow.

The default* props only seed the exchange card; onInputChange is how you follow it. After the first render the card owns its pair and amount, so a token picked, a direction flipped or an amount typed inside it does not change your props. If you show the same trade anywhere else — a summary, a configurator, a URL — listen to onInputChange, which reports { fromChainId, fromAsset, toChainId, toAsset, amount } on every change and once on mount. amount is the bare number as typed; a leg is null until the card has one. It is on StableGenieExchange and StableGenieRwaExchange only.

Coverage

Source chains

Payments are sent from the nine chains where the ZeroDelta escrow is deployed.

Chain
ID
Chain
ID

Ethereum

1

Ink

57073

Optimism

10

Unichain

130

Arbitrum

42161

Plasma

9745

Base

8453

Sonic

146

Plume

98866

Destinations are not limited to that set. The escrow writes the destination chain into the order as a uint64 and the bridge delivers there, so the widget needs no RPC, escrow or wallet on the receiving chain. Many more chains can receive than can send.

Assets

Three nested allow-lists bound what can be named. SupportedAsset is their union — 26 symbols.

List
Members

Executable stables (stable ⇄ stable)

USDC, USDT, USDE, USDTB, AUSD, PYUSD, USDG

Additional RWA-side stables

USD1, WM, USDS, DAI, RLUSD

Tradeable RWA

XAUT, NALPHA, NTBILL, NBASIS, NCREDIT, NOPAL, NWISDOM, NACRDX, NLCRD, NAXI, NCLOA, NFALCON, DEJAAA, DESPXA

The second list is deliberately wider than the first: a stable that cannot execute a stable-to-stable destination leg can still fund an RWA order, which settles on the execution chain it is already on.

These are allow-lists, not the catalogue. What is actually selectable is derived on every page load from the live /chains, /tokens, /routes and /route-health endpoints. The constants only bound it. Never hardcode a token list from this table — read engine.fetchCatalogue() (see API Reference) or let the widget do it for you.

A read that fails yields a catalogue marked ready: false — meaning "not known yet", never "nothing is supported". Only a positive answer may remove an option.

Real-world assets

With product="rwa", exactly one leg of the pair is the real-world asset and the other narrows to stables. Two settlement shapes exist and the engine handles both:

  • Single-leg — XAUT and the Ethereum-home assets settle on the execution chain.

  • Chained — the vault shares and the Centrifuge pair are delivered in two legs. /quote answers isChained: true with two order requests, and the wallet signs them as one submitOrder. The engine binds leg to leg before it will encode anything. The card's Quote details list both orders.

See Integration Guide for what a chained order looks like at the API and contract level.

Compliance

Every quote carries the gateway's compliance decision, and from v4.0.0 the widget discloses it rather than only enforcing it. The exchange and deposit cards carry a Compliance button whose shield turns red on a block; where there is room — the wide card's side panel, the drawer, the deposit card — it also shows the verdict as a pill (Passed, Allowed, Review, Remediate, Blocked, Not screened). It opens the controls that actually ran — which policies applied, which jurisdiction, which wallets were screened. The swap card and the ZeroDelta Link island show the same disclosure as a one-line chip. You do not configure this; it is on.

The jurisdiction shown is the gateway's policy region (EU). From v4.9.0 the panel names the visitor's country beside it (EU · Spain (ES)) when the decision carries sourceCountry — which your proxy adds, since the gateway returns the region only. See The Keyless Proxy → Naming the visitor's country. Without it the panel shows the region alone.

Beside it, a Quote button opens the quote's own facts: the route (Direct, or Via the connector of a chained order), the provider, what is sent and received, the rate, the fee, the network fee and the estimated time.

The five controls a decision can report:

Check
Covers

location

source jurisdiction, OFAC and geo policy

screening

wallet screening across the payment's addresses

risk

risk categories returned by the screening providers

asset

the asset's own policy

limits

amount and velocity policy

Absent is not "passed", and empty is not "none found". A gateway with no compliance gate configured returns no decision at all — the widget renders that as no decision returned, never as green ticks. Separately, a key may be scoped so that sanction categories are withheld from the response, because naming a category back to the subject can amount to tipping off; an empty list therefore means "not disclosed to this key", never "clean". If you build your own UI on the engine, use the exported complianceView() and complianceChecks() rather than reimplementing this — ComplianceView.blocked is true only for an explicit deny and is never inferred from an absence.

Direct settlement is screened too

A direct settlement is a plain transfer: it never calls /quote, so it never reaches the gateway's pre-quote screen. It is gated instead by a standalone wallet screen, which your proxy must expose as screen/wallet. Omit that path and direct settlement disappears from the picker — the engine reads the resulting 404 as "could not screen" and fails closed. Verdicts are cached for five minutes per address.

Handling a block

A rejection arrives as a 422 with the structured code COMPLIANCE_BLOCKED, which the widget relabels into a machine-readable block plus a sentence the payer can act on:

Category
Rendered as "the recipient is …"

STOLEN_FUNDS

flagged as associated with stolen funds

SANCTIONS / SANCTIONED / OFAC

on a sanctions list

TERRORIST_FINANCING

flagged for terrorist financing

FRAUD

flagged for fraud

SCAM

flagged as a scam address

anything else

flagged by compliance screening

A block is never bypassed. Do not retry it and do not offer a workaround — surface the message and let the user change wallet or recipient.

Upstream error text is scrubbed of URLs, host:port pairs, filesystem paths and stack frames and bounded to 160 characters before display; the original still reaches onError for your logs.