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.
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.
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
themeis the one field that merges. A widget's own props replace the provider's values, excepttheme, which is shallow-merged ({ ...context.theme, ...props.theme }). So a provider can setmode: "dark"globally and one widget can overrideaccentalone.
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.
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
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
—
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.
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
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.
recipienthas 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.
defaultFromChainId
number
42161
defaultFromAsset
SupportedAsset
"USDT"
defaultToChainId
number
8453
defaultToAsset
SupportedAsset
"USDC"
defaultAmount
string
""
recipient
string
the payer
variant
"pill"
full card
PaymentIsland — ZeroDelta Link
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.
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
localStorageundersg: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 atransform,filterorcontain: 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_addEthereumChainmetadata 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
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)
onSuccessmeans the money moved. It fires only when the transaction is mined with a success receipt (verdict.status === "confirmed"). A broadcast-but-unmined transaction resolves asunconfirmedand a failed one asreverted; neither firesonSuccess. 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;onInputChangeis 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 toonInputChange, which reports{ fromChainId, fromAsset, toChainId, toAsset, amount }on every change and once on mount.amountis the bare number as typed; a leg isnulluntil the card has one. It is onStableGenieExchangeandStableGenieRwaExchangeonly.
Coverage
Source chains
Payments are sent from the nine chains where the ZeroDelta escrow is deployed.
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
uint64and 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.
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,/routesand/route-healthendpoints. The constants only bound it. Never hardcode a token list from this table — readengine.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.
/quoteanswersisChained: truewith two order requests, and the wallet signs them as onesubmitOrder. 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:
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()andcomplianceChecks()rather than reimplementing this —ComplianceView.blockedis 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:
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.

