@aori/mega-swap-widget
v0.1.14
Published
Embeddable cross-chain swap widget powered by Aori
Readme
@aori/mega-swap-widget
Embeddable cross-chain swap widget powered by Aori.
Install
npm install @aori/mega-swap-widget
# or
yarn add @aori/mega-swap-widget
# or
bun add @aori/mega-swap-widgetPeer Dependencies
| Package | Version | Required |
| ------------------------------- | -------- | -------- |
| wagmi | ^2 | Yes |
| @wagmi/core | ^2 | Yes |
| viem | ^2 | Yes |
| @tanstack/react-query | ≥5 | Yes |
| zustand | ^4 / ^5 | Yes |
| abitype | ≥1 | Optional |
Quick Start
1. Configure the widget
import type { AoriSwapWidgetConfig } from '@aori/mega-swap-widget';
const config: AoriSwapWidgetConfig = {
vtApiBaseUrl: '/api/vt',
walletConnectProjectId: 'YOUR_WALLETCONNECT_PROJECT_ID',
rpcOverrides: {
1: '/api/rpc/1',
10: '/api/rpc/10',
8453: '/api/rpc/8453',
42161: '/api/rpc/42161',
// ...
},
theme: {
mode: 'dark',
dark: { /* color overrides */ },
light: { /* color overrides */ },
},
// All token/chain fields are optional — omit to allow all supported tokens and chains.
tokens: {
defaultBase: { chainId: 1, address: '0x...' },
defaultQuote: { chainId: 4326, address: '0x...' },
supportedInputTokens: [
{ chainId: 1, address: '0x...' },
// ...
],
supportedOutputTokens: [
{ chainId: 4326, address: '0x...' },
// ...
],
supportedInputChains: [1, 10, 8453, 42161],
supportedOutputChains: [4326],
enabledChains: [1, 10, 56, 143, 4326, 8453, 42161],
lockBase: false,
lockQuote: false,
disableInverting: true,
},
appearance: {
widgetType: 'default',
tokenDisplay: 'default',
fillContainer: false,
hideBorder: true,
walletButtonEnabled: false,
},
settings: {
defaultSlippage: 0.01,
},
};2. Import styles
import '@aori/mega-swap-widget/styles.css';Add this import once in your app's entry point (e.g. _app.tsx, layout.tsx, or main.tsx). Without it, the widget will render unstyled.
3. Render
import { SwapWidget } from '@aori/mega-swap-widget';
import { ConnectButton, useConnectModal, useAccountModal } from '@rainbow-me/rainbowkit';
export default function App() {
const { openConnectModal } = useConnectModal();
const { openAccountModal } = useAccountModal();
return (
<div>
<ConnectButton />
<SwapWidget
config={config}
customWalletUI="provider"
onRequestConnect={() => openConnectModal?.()}
onRequestAccount={() => openAccountModal?.()}
/>
</div>
);
}Wallet Connection
The widget expects your app to provide the wallet context (wagmi, RainbowKit, AppKit, etc.). A typical integration uses customWalletUI="provider" with onRequestConnect and onRequestAccount to wire the widget's connect/account buttons into your existing wallet modals — this is the recommended approach for most integrators.
Provider (recommended)
Wrap the widget inside your existing wallet provider. Pass onRequestConnect and onRequestAccount so the widget's built-in buttons open your modals. The widget inherits the wagmi context automatically.
import { useConnectModal, useAccountModal } from '@rainbow-me/rainbowkit';
function SwapPage() {
const { openConnectModal } = useConnectModal();
const { openAccountModal } = useAccountModal();
return (
<SwapWidget
config={config}
customWalletUI="provider"
onRequestConnect={() => openConnectModal?.()}
onRequestAccount={() => openAccountModal?.()}
/>
);
}This works the same way with AppKit or any other wagmi-compatible wallet kit — just swap the modal hooks.
Chain & RPC Configuration
Your wagmi config must include all Aori-supported chains or approvals, wrapping/unwrapping, and chain switching will fail silently.
Import wagmiChains and buildTransports from the widget package — these provide every supported chain with built-in RPC fallback (multiple public RPCs per chain, tried in order on error or rate-limit).
import { wagmiChains, buildTransports } from '@aori/mega-swap-widget';
import { getDefaultConfig } from '@rainbow-me/rainbowkit';
import { aoriConfig } from './aori.config';
const wagmiConfig = getDefaultConfig({
appName: 'My App',
projectId: 'YOUR_WALLETCONNECT_PROJECT_ID',
chains: wagmiChains,
transports: buildTransports(aoriConfig.rpcOverrides),
ssr: false,
});There are two separate RPC paths in the widget:
| Path | Source | Used for |
| --- | --- | --- |
| Widget internal | rpcOverrides in AoriSwapWidgetConfig | Balance fetching, quote pricing |
| Wagmi | buildTransports() in your wagmi config | Signing, sending txs, chain switching |
rpcOverrides in your aori.config only affects the widget's internal viem clients. Wagmi transports are configured separately via buildTransports() — any URLs passed there are client-side JavaScript and will be visible in the browser. Never pass private RPC URLs directly to buildTransports.
To keep private RPCs server-side for both paths, use the same relative proxy paths (e.g. /api/rpc/1) in your rpcOverrides and pass them to buildTransports as shown above. The browser hits your API route, which forwards to the real RPC with credentials from environment variables. See Server-Side Proxying for how to set up the proxy routes.
Server-Side Proxying
In production you almost certainly want to keep your API key and private RPC URLs off the client. The widget supports this via two config fields: vtApiBaseUrl and rpcOverrides. Both accept relative paths that hit your own backend routes, which then forward requests upstream with the real credentials attached.
API Key Proxy (vtApiBaseUrl)
Instead of passing apiKey in the client config, point vtApiBaseUrl at your own API route. The widget will send all transfer/quote requests there instead of directly to the Aori API.
Widget config:
const config: AoriSwapWidgetConfig = {
vtApiBaseUrl: '/api/vt',
// no apiKey needed — the server injects it
// ...
};Next.js API route (pages/api/vt/[...path].ts):
import type { NextApiRequest, NextApiResponse } from 'next';
const VT_UPSTREAM = 'https://transfer.layerzero-api.com/v1';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { path } = req.query;
const segments = Array.isArray(path) ? path.join('/') : path ?? '';
const queryString = new URL(req.url!, `http://${req.headers.host}`).search;
const upstreamUrl = `${VT_UPSTREAM}/${segments}${queryString}`;
const apiKey = process.env.VT_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: 'VT_API_KEY not configured' });
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-api-key': apiKey,
};
const upstream = await fetch(upstreamUrl, {
method: req.method,
headers,
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
});
const contentType = upstream.headers.get('content-type') ?? 'application/json';
res.setHeader('Content-Type', contentType);
res.status(upstream.status);
const body = await upstream.text();
res.send(body);
}Then set VT_API_KEY in your .env (or hosting provider's environment variables):
VT_API_KEY=your_aori_api_keyPrivate RPC Proxy (rpcOverrides)
The same pattern works for RPC endpoints. Point each chain's override at a local route that forwards JSON-RPC requests to your private RPC, keeping the URL and any auth tokens server-side.
Widget config:
const config: AoriSwapWidgetConfig = {
vtApiBaseUrl: '/api/vt',
rpcOverrides: {
1: '/api/rpc/1',
10: '/api/rpc/10',
56: '/api/rpc/56',
8453: '/api/rpc/8453',
42161: '/api/rpc/42161',
143: '/api/rpc/143',
4326: '/api/rpc/4326',
},
// ...
};Next.js API route (pages/api/rpc/[chainId].ts):
import type { NextApiRequest, NextApiResponse } from 'next';
const RPC_URLS: Record<string, string | undefined> = {
'1': process.env.ETHEREUM_RPC_URL,
'10': process.env.OPTIMISM_RPC_URL,
'56': process.env.BSC_RPC_URL,
'8453': process.env.BASE_RPC_URL,
'42161': process.env.ARBITRUM_RPC_URL,
'143': process.env.MONAD_RPC_URL,
'4326': process.env.MEGAETH_RPC_URL,
};
function jsonRpcError(res: NextApiResponse, id: unknown, code: number, message: string) {
return res.status(200).json({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const chainId = req.query.chainId as string;
const rpcUrl = RPC_URLS[chainId];
if (!rpcUrl) {
return jsonRpcError(res, null, -32603, `RPC not configured for chain ${chainId}`);
}
try {
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
});
res.setHeader('Cache-Control', 'no-store');
return res.status(200).json(await response.json());
} catch (error) {
return jsonRpcError(res, null, -32603, error instanceof Error ? error.message : 'RPC request failed');
}
}.env:
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
OPTIMISM_RPC_URL=https://opt-mainnet.g.alchemy.com/v2/YOUR_KEY
BSC_RPC_URL=https://bsc-mainnet.g.alchemy.com/v2/YOUR_KEY
BASE_RPC_URL=https://base-mainnet.g.alchemy.com/v2/YOUR_KEY
ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY
MONAD_RPC_URL=https://your-monad-rpc.example.com
MEGAETH_RPC_URL=https://your-megaeth-rpc.example.comNot using Next.js? The same approach works with any backend — Express, Fastify, Cloudflare Workers, etc. The proxy just needs to forward the request body upstream and return the response. The widget doesn't care what's behind the route as long as it speaks the same protocol (REST for VT, JSON-RPC for RPCs).
Props
| Prop | Type | Default | Description |
| -------------------- | ---------------------------------------- | -------------- | ------------------------------------------------ |
| config | AoriSwapWidgetConfig | — | Widget configuration (required) |
| customWalletUI | 'none' \| 'provider' | 'provider' | 'provider' wires into your wallet modals via callbacks; 'none' hides all wallet UI |
| onRequestConnect | () => void | — | Open the host app's connect modal |
| onRequestAccount | () => void | — | Open the host app's account modal |
| onSwapSubmitted | (quoteId: string) => void | — | Fires immediately after the user's signing/transaction steps complete and the order is submitted to the Aori backend. quoteId is the VT quote ID for tracking. |
| onSwapComplete | (data: SwapCompleteData) => void | — | Fires when polling confirms the swap is settled on-chain. Returns { quoteId, aoriOrderHash, explorerUrl, details } where details is the full Aori /data/details response. |
| onBaseTokenChange | (token: Asset) => void | — | Callback when the base token changes |
| onQuoteTokenChange | (token: Asset) => void | — | Callback when the quote token changes |
| className | string | — | Additional CSS class on the root element |
Swap Lifecycle Hooks Example
Use onSwapSubmitted to know when the order hits the Aori backend, and onSwapComplete to get the full details once it settles on-chain:
<SwapWidget
config={aoriConfig}
onSwapSubmitted={(quoteId) => {
// Fires immediately after the user signs and the order is submitted.
// quoteId is the VT quote ID — use it to track the order or just wait for onSwapComplete.
}}
onSwapComplete={({ aoriOrderHash, explorerUrl, details }) => {
// Fires when polling confirms the swap is settled on-chain.
// details is the full response from https://api.aori.io/data/details/${aoriOrderHash}
// Redirect to a confirmation page
router.push(`/confirmation?order=${aoriOrderHash}`);
// Or persist to your own tx history
const getEventTime = (type: string) =>
details.events.find(e => e.event === type)?.timestamp ?? null;
addToHistory({
orderHash: aoriOrderHash,
explorerUrl,
inputAmount: details.inputAmount,
inputChain: details.inputChain,
outputAmount: details.outputAmount,
outputChain: details.outputChain,
srcTx: details.srcTx,
dstTx: details.dstTx,
// individual event timestamps — use these to show status progression in your UI
createdAt: getEventTime('created'),
receivedAt: getEventTime('received'),
completedAt: getEventTime('completed'),
failedAt: getEventTime('failed'),
canceledAt: getEventTime('cancelled'),
});
}}
/>The SwapCompleteData type:
interface SwapCompleteData {
quoteId: string; // VT quote ID (same value as onSwapSubmitted)
aoriOrderHash: string; // Aori order hash, parsed from explorerUrl
explorerUrl: string; // https://aoriscan.io/order/0x...
details: AoriOrderDetails;
}
interface AoriOrderDetails {
orderHash: string;
offerer: string;
recipient: string;
inputToken: string;
inputAmount: string;
inputChain: string;
inputTokenValueUsd: string;
outputToken: string;
outputAmount: string;
outputChain: string;
outputTokenValueUsd: string;
startTime: number;
endTime: number;
timestamp: number;
srcTx: string | null;
dstTx: string | null;
events: Array<{
event: string; // 'created' | 'received' | 'completed' | 'failed' | 'cancelled'
timestamp: number;
}>;
}What You Can Build With These Hooks
The data from onSwapComplete is generic — you decide what to do with it. Some examples:
- Tx history component — render a live list of swaps with status, amounts, and links
- Confirmation page — redirect to a dedicated page after settlement with order details
- Custom notification — show a toast, modal, or banner with the swap result
- Analytics — send swap events to your own backend or analytics pipeline
- Order detail page — use
aoriOrderHashto link to or render a full order breakdown - Just the data — store it in state, context, or your DB and use it however your app needs
The hooks don't dictate any UI. They hand you the data at the right moment — you wire it into whatever component or flow makes sense for your app.
Building a Live Tx History
One common pattern — use both hooks together to maintain a live list. onSwapSubmitted adds a pending entry immediately, onSwapComplete updates it in-place once settled:
const [history, setHistory] = useState([]);
const addOrUpdate = (quoteId, patch) =>
setHistory(prev => {
const exists = prev.find(e => e.quoteId === quoteId);
if (exists) return prev.map(e => e.quoteId === quoteId ? { ...e, ...patch } : e);
return [...prev, { quoteId, status: 'pending', orderHash: null, ...patch }];
});
<SwapWidget
config={aoriConfig}
onSwapSubmitted={(quoteId) => {
addOrUpdate(quoteId, { status: 'pending' });
}}
onSwapComplete={({ quoteId, aoriOrderHash, explorerUrl, details }) => {
const getEventTime = (type) =>
details.events.find(e => e.event === type)?.timestamp ?? null;
addOrUpdate(quoteId, {
status: getEventTime('failed') ? 'failed' : 'completed',
orderHash: aoriOrderHash,
explorerUrl,
inputAmount: details.inputAmount,
inputChain: details.inputChain,
outputAmount: details.outputAmount,
outputChain: details.outputChain,
srcTx: details.srcTx,
dstTx: details.dstTx,
createdAt: getEventTime('created'),
receivedAt: getEventTime('received'),
completedAt: getEventTime('completed'),
failedAt: getEventTime('failed'),
});
}}
/>
// Render history however you like
{history.map(tx => <TxRow key={tx.quoteId} tx={tx} />)}This gives you a real-time list that:
- Shows "pending" the instant the user submits
- Updates to "completed" or "failed" once on-chain settlement confirms
- Has all the data needed to render amounts, chains, tx links, and a status timeline
Fetching Order History Outside the Hook
The onSwapComplete hook only captures swaps from the current session. To load a wallet's full order history — on mount, for a tx history page, etc. — use the Aori API directly with the connected wallet address.
Query all orders for a wallet (/data/query):
const res = await fetch(
`https://api.aori.io/data/query?offerer=[connected_wallet_address]&page=0&limit=100&sortBy=createdAt_desc`
);
const { orders, pagination } = await res.json();
// orders is an array of summarised order objects with srcTx, dstTx, amounts, chains, statusGet full details for a specific order (/data/details):
const res = await fetch(`https://api.aori.io/data/details/${orderHash}`);
const details = await res.json();
// Full event history, tx hashes, block numbers, timestampsThe recommended pattern for a tx history feature:
- On swap completion — write to your own state/DB via
onSwapComplete - On page load — fetch historical orders from
/data/query?offerer=${address}to backfill swaps from past sessions - On detail view — fetch
/data/details/${orderHash}for the full event timeline of a specific order
Configuration
See AoriSwapWidgetConfig for the full type. Only non-default values need to be specified.
| Section | Type | Description |
| --- | --- | --- |
| theme | { mode, light?, dark? } | Active color mode and optional light/dark theme overrides. Each theme controls colors, border radius, fonts, and shadows. |
| tokens | { defaultBase?, defaultQuote?, lockBase?, lockQuote?, enabledChains?, disableInverting?, supportedInputTokens?, supportedOutputTokens? } | Default token pair, enabled chains, lock/invert settings, and per-side token whitelists. All fields are optional — when omitted, all supported tokens and chains are available. |
| appearance | { widgetType?, tokenDisplay?, assetMenuVariant?, swapButtonVariant?, ... } | Layout variant (default, compact, horizontal, split), token display style, asset menu variant, quote loader, and other UI options. |
| settings | { defaultSlippage? } | Default slippage tolerance. |
| integrator | { id?, feeRecipient?, feeAmount? } | Integrator ID, fee recipient address, and fee amount for revenue attribution. |
| vtApiBaseUrl | string | Base URL for the Aori transfer API. Use a relative path (e.g. '/api/vt') to proxy through your server and keep the API key off the client. |
| rpcOverrides | Record<number, string \| string[]> | Override RPCs for the widget's internal viem clients. Accepts direct URLs or relative paths to your own proxy routes. |
Invertible Token Lists
When disableInverting is false (or omitted), users can flip the swap direction using the invert button. When they do, supportedInputTokens and supportedOutputTokens swap sides — meaning token list A becomes the output whitelist and token list B becomes the input whitelist.
This enables bidirectional bridge configurations with a single config. For example, a USDM stablecoin bridge:
tokens: {
supportedInputTokens: [
{ chainId: 1, address: '0x...' }, // USDC Ethereum
{ chainId: 8453, address: '0x...' }, // USDC Base
{ chainId: 42161, address: '0x...' }, // USDC Arbitrum
// ... other stablecoins
],
supportedOutputTokens: [
{ chainId: 4326, address: '0x...' }, // USDM MegaETH
],
supportedInputChains: [1, 8453, 42161],
supportedOutputChains: [4326],
disableInverting: false,
lockBase: false,
lockQuote: false,
}In the default direction, the user picks a stablecoin as input and receives USDM. After inverting, the lists flip — USDM becomes the only input option and the stablecoins become the output options.
Dynamic locking: When a side's effective token list contains exactly one token, that side's token selection is automatically disabled and the dropdown icon is hidden. No need to set lockBase or lockQuote — locking is inferred from the list length and updates dynamically on invert.
Set disableInverting: true to prevent flipping and keep the lists fixed to their configured sides.
Display Patterns
The widget is a standard React component — place it anywhere in your layout.
Inline (default)
<SwapWidget config={config} />Modal / Dialog
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Swap</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
>
<div onClick={(e) => e.stopPropagation()}>
<SwapWidget config={config} />
</div>
</div>
)}
</>
);Drawer / Slide-in
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Swap</button>
<div
className={`fixed top-0 right-0 z-50 h-full w-[420px] bg-background shadow-xl
transition-transform duration-300
${open ? 'translate-x-0' : 'translate-x-full'}`}
>
<button onClick={() => setOpen(false)}>Close</button>
<SwapWidget config={config} />
</div>
{open && (
<div
className="fixed inset-0 z-40 bg-black/30"
onClick={() => setOpen(false)}
/>
)}
</>
);These are minimal examples. In production, use your app's existing modal/drawer components (Radix, shadcn, Headless UI, etc.) for accessibility and consistent behavior.
