docs: add cross-chain swap example

This commit is contained in:
c4605
2025-03-18 00:18:24 +01:00
parent 214e8b9eca
commit 6ced26d445
21 changed files with 3460 additions and 0 deletions

View File

@@ -54,6 +54,7 @@
### Other Changes
- Added cross-chain swap example code for developer reference
- Multiple Stacks contract upgrades
- Support for new fee charge model
- Upgraded viem dependency

24
examples/cross-chain-swap/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,29 @@
# XLink SDK Cross-Chain Swap Demo
This demo project showcases how to implement cross-chain asset exchange functionality using XLink SDK and ALEX SDK.
## Project Overview
This demo application demonstrates how to integrate XLink SDK and ALEX SDK to create a complete cross-chain swap experience. Users can seamlessly transfer and exchange digital assets between different blockchains.
## Usage
### Prerequisites
Ensure you have the following tools installed:
- Node.js
- pnpm (recommended) or npm
### Install Dependencies
```bash
pnpm install
```
### Run Development Server
```bash
pnpm dev
```
The application will run at http://localhost:5173

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
{
"name": "xlink-sdk-cross-chain-swap-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"simulation": "tsx simulation/index.ts"
},
"dependencies": {
"@stacks/common": "^7.0.2",
"@stacks/connect": "^8.1.7",
"@stacks/network": "^7.0.2",
"@stacks/stacks-blockchain-api-types": "^7.14.1",
"@stacks/transactions": "^7.0.5",
"@xlink-network/xlink-sdk": "file:../..",
"alex-sdk": "github:alexgo-io/alex-sdk#feat/detailed-swap-route-info",
"c32check": "^2.0.0",
"lodash-es": "^4.17.21",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-query": "^3.39.3"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"prettier": "^3.5.3",
"stxer": "^0.4.1",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vite": "^6.2.2"
}
}

2141
examples/cross-chain-swap/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 1.8rem;
margin-bottom: 2rem;
color: #1a1a1a;
}
h2 {
font-size: 1.3rem;
margin-top: 1.5rem;
margin-bottom: 1rem;
color: #1a1a1a;
}
h3 {
font-size: 1.1rem;
margin-top: 1rem;
margin-bottom: 0.8rem;
color: #1a1a1a;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
color: white;
cursor: pointer;
transition: border-color 0.25s;
margin: 0.5rem 0;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
button:disabled {
background-color: #666;
cursor: not-allowed;
}
input, select {
border-radius: 8px;
border: 1px solid #d1d5db;
padding: 0.6em 1.2em;
font-size: 1em;
margin: 0.3rem 0;
background-color: #f3f4f6;
color: #1a1a1a;
}
.loading {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px;
text-align: center;
z-index: 100;
}
.routes-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.route-item {
border: 1px solid #ccc;
border-radius: 8px;
padding: 10px;
background-color: #f5f5f5;
text-align: left;
}
.selected-route {
border: 2px solid #646cff;
border-radius: 8px;
padding: 10px;
margin: 10px 0;
background-color: #efefff;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
button {
background-color: #f9f9f9;
color: #213547;
}
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.section {
margin-bottom: 30px;
padding: 20px;
border-radius: 8px;
background-color: #ffffff;
border: 1px solid #e5e5e5;
}
.input-group {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.routes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.route-card {
padding: 15px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f3f4f6;
cursor: pointer;
transition: all 0.2s ease;
}
.route-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background-color: #ffffff;
}
.route-card.selected {
border-color: #646cff;
background-color: #f5f6ff;
}
.route-card p {
margin: 5px 0;
color: #1a1a1a;
}
.routes-section {
margin-top: 20px;
}
.routes-section h3 {
color: #1a1a1a;
margin-bottom: 1rem;
}
.routes-section p {
color: #6b7280;
margin: 0.5rem 0;
}
.bridge-info {
background-color: #ffffff;
padding: 15px;
border-radius: 8px;
border: 1px solid #d1d5db;
}
.bridge-info pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: #1a1a1a;
}
.loading {
text-align: center;
padding: 20px;
font-size: 18px;
color: #666;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f3f4f6;
}
.app-header {
background-color: #ffffff;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-bottom: 1px solid #e5e5e5;
}
.app-header h1 {
margin: 0;
color: #1a1a1a;
}
.app-main {
flex: 1;
padding: 2rem 0;
}
.content-wrapper {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.app-footer {
background-color: #ffffff;
padding: 1.5rem;
text-align: center;
border-top: 1px solid #e5e5e5;
}
.app-footer p {
margin: 0;
color: #6b7280;
font-size: 0.9rem;
}
.swap-group {
display: flex;
gap: 1rem;
align-items: flex-start;
max-width: 800px;
margin: 0 auto;
}
.route-select {
flex: 2;
}
.amount-input {
flex: 1;
}
.route-dropdown {
width: 100%;
padding: 0.8rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f3f4f6;
font-size: 1rem;
color: #1a1a1a;
cursor: pointer;
transition: all 0.2s ease;
}
.route-dropdown:hover {
border-color: #646cff;
background-color: #ffffff;
}
.route-dropdown:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2);
background-color: #ffffff;
}
.amount-field {
width: 100%;
padding: 0.8rem;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f3f4f6;
font-size: 1rem;
color: #1a1a1a;
transition: all 0.2s ease;
}
.amount-field:hover {
border-color: #646cff;
background-color: #ffffff;
}
.amount-field:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2);
background-color: #ffffff;
}
.no-routes {
color: #6b7280;
font-size: 0.9rem;
padding: 1rem;
text-align: center;
background-color: #f3f4f6;
border-radius: 8px;
border: 1px solid #d1d5db;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 1rem;
text-align: center;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
background-color: #f3f4f6;
border-radius: 8px;
border: 1px solid #d1d5db;
}
.loading-container p {
color: #6b7280;
margin: 0;
}
.loading-spinner {
width: 30px;
height: 30px;
border: 3px solid #f3f4f6;
border-top: 3px solid #646cff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,29 @@
import { XLinkSDK } from "@xlink-network/xlink-sdk"
import { AlexSDK } from "alex-sdk"
import { FC } from "react"
import "./App.css"
import { SwapRouteSelector } from "./components/SwapRouteSelector"
import { QueryClient, QueryClientProvider } from "react-query"
const alex = new AlexSDK()
const xlink = new XLinkSDK()
const queryClient = new QueryClient()
const App: FC = () => {
return (
<QueryClientProvider client={queryClient}>
<div className="app-container">
<header className="app-header">
<h1>XLink Cross-Chain Swap Demo</h1>
</header>
<main className="app-main">
<div className="content-wrapper">
<SwapRouteSelector alexSDK={alex} xlinkSDK={xlink} />
</div>
</main>
</div>
</QueryClientProvider>
)
}
export default App

View File

@@ -0,0 +1,319 @@
import {
KnownChainId,
KnownRoute,
StacksContractAddress,
SwapRoute_WithExchangeRate,
toSDKNumberOrUndefined,
XLinkSDK,
} from "@xlink-network/xlink-sdk"
import { AlexSDK } from "alex-sdk"
import { FC, Fragment, useEffect, useState } from "react"
import { useQuery } from "react-query"
import { getAvailableRoutes } from "../utils/getAvailableRoutes"
import { getSwapRoutesViaALEX } from "../utils/getSwapRoutesViaALEX"
import { getSwapRoutesViaEVMDEX } from "../utils/getSwapRoutesViaEVMDEX"
import { formatXLinkSDKChainName } from "../utils/formatXLinkSDKChainName"
import { useDebouncedValue } from "../hooks/useDebouncedValue"
const STORAGE_KEY = "xlink_matcha_api_key"
export const SwapRouteSelector: FC<{
alexSDK: AlexSDK
xlinkSDK: XLinkSDK
}> = ({ alexSDK, xlinkSDK }) => {
const [matchaAPIKey, setMatchaAPIKey] = useState(() => {
return localStorage.getItem(STORAGE_KEY) || ""
})
const [swapAmount, setSwapAmount] = useState("")
const [selectedRoute, setSelectedRoute] = useState<null | KnownRoute>(null)
const [selectedSwapRoute, setSelectedSwapRoute] =
useState<null | SwapRoute_WithExchangeRate>(null)
const debouncedSwapAmount = useDebouncedValue(swapAmount, 500)
const debouncedRoute = useDebouncedValue(selectedRoute, 500)
const debouncedAPIKey = useDebouncedValue(matchaAPIKey, 500)
useEffect(() => {
localStorage.setItem(STORAGE_KEY, matchaAPIKey)
}, [matchaAPIKey])
const availableRoutes = useQuery({
queryKey: ["availableRoutes"],
queryFn: () => getAvailableRoutes(xlinkSDK),
})
const alexRoutes = useQuery({
enabled: !!debouncedRoute && !!debouncedSwapAmount,
queryKey: [
"alexRoutes",
JSON.stringify(debouncedRoute),
debouncedSwapAmount,
],
queryFn: () => {
if (debouncedRoute == null) {
throw new Error("No route selected")
}
if (!isNumber(debouncedSwapAmount)) {
throw new Error("No swap amount")
}
return getSwapRoutesViaALEX(
{
alexSDK: alexSDK,
xlinkSDK: xlinkSDK,
},
{
...debouncedRoute,
amount: toSDKNumberOrUndefined(Number(debouncedSwapAmount)),
slippage: toSDKNumberOrUndefined(0.01),
},
)
},
})
const evmDexRoutes = useQuery({
enabled: !!debouncedRoute && !!debouncedSwapAmount && !!debouncedAPIKey,
queryKey: [
"evmDexRoutes",
JSON.stringify(debouncedRoute),
debouncedSwapAmount,
],
queryFn: () => {
if (debouncedRoute == null) {
throw new Error("No route selected")
}
if (!debouncedAPIKey) {
throw new Error("No matcha API key")
}
if (!isNumber(debouncedSwapAmount)) {
throw new Error("No swap amount")
}
return getSwapRoutesViaEVMDEX(
{
xlinkSDK: xlinkSDK,
matchaAPIKey: debouncedAPIKey,
},
{
...debouncedRoute,
amount: toSDKNumberOrUndefined(Number(debouncedSwapAmount)),
slippage: toSDKNumberOrUndefined(0.01),
},
)
},
})
const bridgeInfo = useQuery({
enabled: !!selectedRoute && !!selectedSwapRoute,
queryKey: [
"bridgeInfo",
JSON.stringify(selectedRoute),
JSON.stringify(selectedSwapRoute),
],
queryFn: () => {
if (selectedRoute == null) {
throw new Error("No route selected")
}
if (selectedSwapRoute == null) {
throw new Error("No swap route selected")
}
if (KnownChainId.isBitcoinChain(selectedRoute.fromChain)) {
return xlinkSDK.bridgeInfoFromBitcoin({
...selectedRoute,
swapRoute: selectedSwapRoute,
amount: toSDKNumberOrUndefined(Number(swapAmount)),
})
}
if (KnownChainId.isBRC20Chain(selectedRoute.fromChain)) {
return xlinkSDK.bridgeInfoFromBRC20({
...selectedRoute,
swapRoute: selectedSwapRoute,
amount: toSDKNumberOrUndefined(Number(swapAmount)),
})
}
if (KnownChainId.isRunesChain(selectedRoute.fromChain)) {
return xlinkSDK.bridgeInfoFromRunes({
...selectedRoute,
swapRoute: selectedSwapRoute,
amount: toSDKNumberOrUndefined(Number(swapAmount)),
})
}
if (KnownChainId.isEVMChain(selectedRoute.fromChain)) {
throw new Error("EVM chain not support cross-chain swap yet")
}
if (KnownChainId.isStacksChain(selectedRoute.fromChain)) {
throw new Error("Stacks chain not support cross-chain swap yet")
}
throw new Error("Unsupported chain: " + selectedRoute.fromChain)
},
})
return (
<div className="container">
{availableRoutes.isLoading && (
<div className="loading-overlay">
<div className="loading-spinner"></div>
<p>Loading routes...</p>
</div>
)}
<div className="section">
<h2>Basic Information</h2>
<div className="input-group">
<input
type="text"
placeholder="Enter 0x API Key"
value={matchaAPIKey}
onChange={e => setMatchaAPIKey(e.target.value)}
/>
</div>
</div>
<div className="section">
<h2>Swap Settings</h2>
<div className="swap-group">
<div className="route-select">
<select
value={selectedRoute ? JSON.stringify(selectedRoute) : ""}
onChange={e =>
setSelectedRoute(
e.target.value ? JSON.parse(e.target.value) : null,
)
}
className="route-dropdown"
disabled={availableRoutes.isLoading}
>
<option value="">Select Route</option>
{availableRoutes.data?.map((route, index) => (
<option key={index} value={JSON.stringify(route)}>
{route.fromTokenName} (
{formatXLinkSDKChainName(route.fromChain)}) {" "}
{route.toTokenName} ({formatXLinkSDKChainName(route.toChain)})
</option>
))}
</select>
</div>
<div className="amount-input">
<input
type="number"
placeholder="Enter swap amount"
value={swapAmount}
onChange={e => setSwapAmount(e.target.value)}
className="amount-field"
disabled={availableRoutes.isLoading}
/>
</div>
</div>
</div>
{selectedRoute && (
<div className="section">
<h2>Swap Routes</h2>
<div className="routes-section">
<h3>via ALEX</h3>
{alexRoutes.isLoading ? (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading ALEX routes...</p>
</div>
) : alexRoutes.data?.type === "success" ? (
<div className="routes-grid">
{alexRoutes.data.swapRoutes.map((route, index) => (
<div
key={index}
className={`route-card ${selectedSwapRoute === route ? "selected" : ""}`}
onClick={() => setSelectedSwapRoute(route)}
>
<p>
<StacksTokenName address={route.fromTokenAddress} />
{route.swapPools.map((p, index) => (
<Fragment key={index}>
&nbsp;&nbsp;
<StacksTokenName address={p.toTokenAddress} />
</Fragment>
))}
</p>
<p>Exchange Rate: {route.composedExchangeRate}</p>
<p>Minimum Receive: {route.minimumAmountsToReceive}</p>
</div>
))}
</div>
) : (
<p className="no-routes">No ALEX routes available</p>
)}
</div>
<div className="routes-section">
<h3>via EVM DEX</h3>
{evmDexRoutes.isLoading ? (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading EVM DEX routes...</p>
</div>
) : evmDexRoutes.data?.type === "success" ? (
<div className="routes-grid">
{evmDexRoutes.data.swapRoutes.map((route, index) => (
<div
key={index}
className={`route-card ${selectedSwapRoute === route ? "selected" : ""}`}
onClick={() => setSelectedSwapRoute(route)}
>
<p>Route {index + 1}</p>
<p>Chain: {route.evmChain}</p>
<p>Exchange Rate: {route.composedExchangeRate}</p>
<p>Minimum Receive: {route.minimumAmountsToReceive}</p>
</div>
))}
</div>
) : (
<p className="no-routes">No EVM DEX routes available</p>
)}
</div>
</div>
)}
<div className="section">
<h2>Bridge Information</h2>
{selectedSwapRoute && (
<>
{bridgeInfo.isLoading ? (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading bridge information...</p>
</div>
) : (
bridgeInfo.data && (
<div className="bridge-info">
<pre>{JSON.stringify(bridgeInfo.data, null, 2)}</pre>
</div>
)
)}
</>
)}
</div>
</div>
)
}
const StacksTokenName: FC<{
address: StacksContractAddress
}> = ({ address }) => {
return (
<abbr title={`${address.deployerAddress}.${address.contractName}`}>
{address.contractName}
</abbr>
)
}
const isNumber = (value: string): value is `${number}` => {
if (value === "") return false
const num = Number(value)
return !isNaN(num) && isFinite(num) && num > 0
}

View File

@@ -0,0 +1,16 @@
import { useEffect, useState } from "react"
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timeoutId)
}
}, [value, delay])
return debouncedValue
}

View File

@@ -0,0 +1,62 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,10 @@
import React from "react"
import ReactDOM from "react-dom/client"
import App from "./App"
import "./index.css"
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,72 @@
import { KnownChainId } from "../../node_modules/@xlink-network/xlink-sdk/lib/utils/types/knownIds"
export const formatXLinkSDKChainName = (
chain: KnownChainId.KnownChain,
): string => {
if (KnownChainId.isBitcoinChain(chain)) {
return "Bitcoin"
}
if (KnownChainId.isBRC20Chain(chain)) {
return "BRC-20"
}
if (KnownChainId.isRunesChain(chain)) {
return "Runes"
}
if (KnownChainId.isStacksChain(chain)) {
return "Stacks"
}
if (KnownChainId.isEVMChain(chain)) {
switch (chain) {
case KnownChainId.EVM.Ethereum:
return "Ethereum"
case KnownChainId.EVM.Sepolia:
return "Sepolia"
case KnownChainId.EVM.BSC:
return "BSC"
case KnownChainId.EVM.BSCTestnet:
return "BSC Testnet"
case KnownChainId.EVM.CoreDAO:
return "CoreDAO"
case KnownChainId.EVM.CoreDAOTestnet:
return "CoreDAO Testnet"
case KnownChainId.EVM.Bsquared:
return "B²"
case KnownChainId.EVM.BOB:
return "BOB"
case KnownChainId.EVM.Bitlayer:
return "Bitlayer"
case KnownChainId.EVM.Lorenzo:
return "Lorenzo"
case KnownChainId.EVM.Merlin:
return "Merlin"
case KnownChainId.EVM.AILayer:
return "AILayer"
case KnownChainId.EVM.Mode:
return "Mode"
case KnownChainId.EVM.XLayer:
return "XLayer"
case KnownChainId.EVM.Arbitrum:
return "Arbitrum"
case KnownChainId.EVM.Aurora:
return "Aurora"
case KnownChainId.EVM.Manta:
return "Manta"
case KnownChainId.EVM.Linea:
return "Linea"
case KnownChainId.EVM.Base:
return "Base"
case KnownChainId.EVM.BlifeTestnet:
return "Blife Testnet"
case KnownChainId.EVM.BeraTestnet:
return "Bera Testnet"
default:
return "Unknown EVM Chain"
}
}
return chain
}

View File

@@ -0,0 +1,68 @@
import {
KnownChainId,
KnownRoute,
KnownTokenId,
XLinkSDK,
} from "@xlink-network/xlink-sdk"
export const getAvailableRoutes = async (
xlinkSDK: XLinkSDK,
): Promise<(KnownRoute & { fromTokenName: string; toTokenName: string })[]> => {
const routes = await _getAvailableRoutes(xlinkSDK)
return routes.map(
(route): KnownRoute & { fromTokenName: string; toTokenName: string } =>
({
fromChain: route[0][0],
fromToken: route[0][1],
fromTokenName: route[0][2],
toChain: route[1][0],
toToken: route[1][1],
toTokenName: route[1][2],
}) as any,
)
}
type ChainTokenPair = readonly [
chain: KnownChainId.KnownChain,
token: KnownTokenId.KnownToken,
tokenName: string,
]
type AvailableRoute = readonly [from: ChainTokenPair, to: ChainTokenPair]
const _getAvailableRoutes = async (
xlinkSDK: XLinkSDK,
): Promise<AvailableRoute[]> => {
const alexBrc20 = await xlinkSDK.brc20TickToBRC20Token(
KnownChainId.BRC20.Mainnet,
"alex$",
)
const ausdBrc20 = await xlinkSDK.brc20TickToBRC20Token(
KnownChainId.BRC20.Mainnet,
"ausd$",
)
const result: [from: ChainTokenPair, to: ChainTokenPair][] = []
if (alexBrc20 != null) {
result.push([
[KnownChainId.Bitcoin.Mainnet, KnownTokenId.Bitcoin.BTC, "BTC"],
[KnownChainId.BRC20.Mainnet, alexBrc20, "alex$"],
])
result.push([
[KnownChainId.BRC20.Mainnet, alexBrc20, "alex$"],
[KnownChainId.Bitcoin.Mainnet, KnownTokenId.Bitcoin.BTC, "BTC"],
])
}
if (ausdBrc20 != null) {
result.push([
[KnownChainId.Bitcoin.Mainnet, KnownTokenId.Bitcoin.BTC, "BTC"],
[KnownChainId.BRC20.Mainnet, ausdBrc20, "ausd$"],
])
result.push([
[KnownChainId.BRC20.Mainnet, ausdBrc20, "ausd$"],
[KnownChainId.Bitcoin.Mainnet, KnownTokenId.Bitcoin.BTC, "BTC"],
])
}
return result
}

View File

@@ -0,0 +1,107 @@
import {
KnownChainId,
KnownRoute,
SDKNumber,
SwapRouteViaALEX_WithExchangeRate,
SwapRouteViaALEX_WithMinimumAmountsOut,
toSDKNumberOrUndefined,
XLinkSDK,
} from "@xlink-network/xlink-sdk"
import { getALEXSwapParameters } from "@xlink-network/xlink-sdk/swapHelpers"
import { AlexSDK } from "alex-sdk"
import { sortBy, uniqBy } from "lodash-es"
export async function getSwapRoutesViaALEX(
context: {
alexSDK: AlexSDK
xlinkSDK: XLinkSDK
},
swapRequest: KnownRoute & {
amount: SDKNumber
slippage: SDKNumber
},
): Promise<
| { type: "failed"; reason: "unsupported-route" }
| {
type: "success"
swapRoutes: (SwapRouteViaALEX_WithExchangeRate &
SwapRouteViaALEX_WithMinimumAmountsOut)[]
}
> {
const { alexSDK, xlinkSDK } = context
const swapParameters = await getALEXSwapParameters(xlinkSDK, swapRequest)
if (swapParameters == null) {
return { type: "failed", reason: "unsupported-route" }
}
if (
// ALEX SDK does not support testnet
swapParameters.stacksChain === KnownChainId.Stacks.Testnet
) {
return { type: "failed", reason: "unsupported-route" }
}
const [fromTokenAddress, toTokenAddress] = await Promise.all([
xlinkSDK.stacksAddressFromStacksToken(
swapParameters.stacksChain,
swapParameters.fromToken,
),
xlinkSDK.stacksAddressFromStacksToken(
swapParameters.stacksChain,
swapParameters.toToken,
),
])
if (fromTokenAddress == null || toTokenAddress == null) {
return { type: "failed", reason: "unsupported-route" }
}
const [fromCurrency, toCurrency] = await Promise.all([
alexSDK.fetchTokenInfo(
`${fromTokenAddress.deployerAddress}.${fromTokenAddress.contractName}`,
),
alexSDK.fetchTokenInfo(
`${toTokenAddress.deployerAddress}.${toTokenAddress.contractName}`,
),
])
if (fromCurrency == null || toCurrency == null) {
return { type: "failed", reason: "unsupported-route" }
}
const routes = await alexSDK.getAllPossibleRoutesWithDetails(
fromCurrency.id,
toCurrency.id,
toBigInt(Number(swapRequest.amount), fromCurrency.wrapTokenDecimals),
)
if (routes.length === 0) {
return { type: "failed", reason: "unsupported-route" }
}
return {
type: "success",
swapRoutes: uniqBy(
sortBy(routes, r => r.toAmount),
r => r.toAmount,
)
.slice(0, 5)
.map(r => ({
...r,
via: "ALEX",
minimumAmountsToReceive: toSDKNumberOrUndefined(
toNumber(r.toAmount, toCurrency.wrapTokenDecimals) *
(1 - Number(swapRequest.slippage)),
),
composedExchangeRate: toSDKNumberOrUndefined(
toNumber(r.toAmount, toCurrency.wrapTokenDecimals) /
toNumber(r.fromAmount, fromCurrency.wrapTokenDecimals),
),
})),
}
}
function toNumber(value: bigint, moveDecimalPlaces: number): number {
return Number(value) / 10 ** moveDecimalPlaces
}
function toBigInt(value: number, moveDecimalPlaces: number): bigint {
return BigInt(Math.floor(value * 10 ** moveDecimalPlaces))
}

View File

@@ -0,0 +1,72 @@
import {
KnownRoute,
SDKNumber,
SwapRouteViaEVMDexAggregator_WithExchangeRate,
SwapRouteViaEVMDexAggregator_WithMinimumAmountsOut,
toSDKNumberOrUndefined,
XLinkSDK,
} from "@xlink-network/xlink-sdk"
import {
getDexAggregatorRoutes,
getPossibleEVMDexAggregatorSwapParameters,
fetchMatchaPossibleRoutesFactory,
} from "@xlink-network/xlink-sdk/swapHelpers"
export async function getSwapRoutesViaEVMDEX(
context: {
xlinkSDK: XLinkSDK
matchaAPIKey: string
},
swapRequest: KnownRoute & {
amount: SDKNumber
slippage: SDKNumber
},
): Promise<
| { type: "failed"; reason: "unsupported-route" }
| {
type: "success"
swapRoutes: (SwapRouteViaEVMDexAggregator_WithExchangeRate &
SwapRouteViaEVMDexAggregator_WithMinimumAmountsOut)[]
}
> {
const { xlinkSDK } = context
const possibleSwapParameters =
await getPossibleEVMDexAggregatorSwapParameters(xlinkSDK, swapRequest)
if (possibleSwapParameters.length === 0) {
return { type: "failed", reason: "unsupported-route" }
}
const routes = await getDexAggregatorRoutes(xlinkSDK, {
routeFetcher: fetchMatchaPossibleRoutesFactory({
baseUrl: "/api/matcha",
apiKey: context.matchaAPIKey,
}),
routes: possibleSwapParameters.map(p => ({
evmChain: p.evmChain,
fromToken: p.fromToken,
toToken: p.toToken,
amount: p.fromAmount,
slippage: swapRequest.slippage,
})),
})
if (routes == null) {
return { type: "failed", reason: "unsupported-route" }
}
return {
type: "success",
swapRoutes: routes.map(r => ({
via: "evmDexAggregator",
evmChain: r.evmChain,
fromEVMToken: r.fromToken,
toEVMToken: r.toToken,
composedExchangeRate: toSDKNumberOrUndefined(
Number(r.toAmount) / Number(r.fromAmount),
),
minimumAmountsToReceive: toSDKNumberOrUndefined(
Number(r.toAmount) * (1 - Number(swapRequest.slippage)),
),
})),
}
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: "/xlink-sdk-example/cross-chain-swap/",
server: {
proxy: {
"/api/matcha": {
target: "https://api.0x.org",
changeOrigin: true,
rewrite: path => path.replace(/^\/api\/matcha/, ""),
configure: (proxy, _options) => {
proxy.on("error", (err, _req, _res) => {
console.log("proxy error", err)
})
proxy.on("proxyReq", (proxyReq, req, _res) => {
console.log("Sending Request to the Target:", req.method, req.url)
})
proxy.on("proxyRes", (proxyRes, req, _res) => {
console.log(
"Received Response from the Target:",
proxyRes.statusCode,
req.url,
)
})
},
},
},
},
})