Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
25630c2
chore: add swaps client
slavastartsev Dec 1, 2025
2086ef6
refactor: rename cross-chain to evm-to-evm
Orland0x Dec 1, 2025
9793cbd
refactor: order types
Orland0x Dec 2, 2025
680b54f
feat: cross chain swap client base
Orland0x Dec 2, 2025
f43b22b
chore: fixed test
Orland0x Dec 2, 2025
c839017
feat: added evm to evm orders to cross chain swap client
Orland0x Dec 2, 2025
88e4bdc
feat: add SwapsClient to CrossChainHatewayClient
Orland0x Dec 2, 2025
aa828c8
feat: added swaps quoter and fix types
Orland0x Dec 2, 2025
5b2d178
chore: remove broken exports
slavastartsev Dec 2, 2025
2d4213f
chore: export cross chain swap client
slavastartsev Dec 2, 2025
e92d0a1
Merge branch 'master' of https://github.com/bob-collective/bob into f…
slavastartsev Dec 2, 2025
bebd700
chore: export types
slavastartsev Dec 2, 2025
02747af
chore: fix types
slavastartsev Dec 3, 2025
94f04ea
Merge branch 'master' of https://github.com/bob-collective/bob into f…
slavastartsev Dec 3, 2025
0868fa4
feat: onramp with swaps client
Orland0x Dec 4, 2025
e32e098
chore: comment
Orland0x Dec 4, 2025
3698746
feat: offramp with swaps handler
Orland0x Dec 4, 2025
cc66325
chore: temp public access to swaps api key
slavastartsev Dec 4, 2025
f958e79
chore: updated tests
Orland0x Dec 4, 2025
d2f2670
chore: add bnb
slavastartsev Dec 5, 2025
dcd4f72
chore: add more chains for stablecoins (#891)
slavastartsev Dec 5, 2025
d88e0e9
chore: rc0 version
slavastartsev Dec 5, 2025
7f76a95
chore: version 4.4.10-rc1
slavastartsev Dec 5, 2025
7ede063
chore: comment out from user address
slavastartsev Dec 8, 2025
bbfc1f4
chore: bump version
slavastartsev Dec 8, 2025
b7fac7c
chore: set default from address for offramp
slavastartsev Dec 8, 2025
d744ee1
chore: rc3
slavastartsev Dec 8, 2025
75c0500
chore: update swaps api key path
slavastartsev Dec 8, 2025
be6007b
chore: restore public key
slavastartsev Dec 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
468 changes: 468 additions & 0 deletions sdk/src/gateway/cross-chain-swap.ts

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions sdk/src/gateway/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
export { OkxWalletAdapter } from './adapters/okx-wallet';
export { ReownWalletAdapter } from './adapters/reown';
export { GatewayApiClient as GatewaySDK } from './client';
export { CrossChainSwapGatewayClient } from './cross-chain-swap';
export { LayerZeroGatewayClient } from './layerzero';
export {
CrossChainOrder,
CrossChainOrderStatus,
CrossChainSwapQuote,
CrossChainSwapQuoteParams,
EVMToEVMWithLayerZeroOrder,
EVMToEVMWithLayerZeroOrderStatus,
EVMToEVMWithLayerZeroQuote,
EVMToEVMWithLayerZeroExecuteQuoteParams,
EVMToEVMWithLayerZeroFeeBreakdown,
ExecuteQuoteParams,
GatewayOrderType,
GatewayQuoteParams,
Expand Down
104 changes: 59 additions & 45 deletions sdk/src/gateway/layerzero.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
encodeAbiParameters,
encodePacked,
erc20Abi,
getAddress,
Hex,
InsufficientFundsError,
isAddress,
Expand All @@ -24,27 +25,22 @@ import { layerZeroOftAbi, quoterV2Abi } from './abi';
import { AllWalletClientParams, GatewayApiClient } from './client';
import { getTokenAddress, getTokenSlots } from './tokens';
import {
CrossChainOrder,
EVMToEVMWithLayerZeroOrder,
EVMToEVMWithLayerZeroQuote,
ExecuteQuoteParams,
GatewayOrder,
GatewayOrderType,
GetQuoteParams,
LayerZeroChainInfo,
LayerZeroDeploymentsMetadataResponse,
LayerZeroMessagesWalletResponse,
LayerZeroQuoteParamsExt,
LayerZeroSendParam,
LayerZeroTokenDeploymentsResponse,
CrossChainSwapQuoteParamsExt,
} from './types';
import {
computeAllowanceSlot,
computeBalanceSlot,
getChainConfig,
getCrossChainStatus,
toHexScriptPubKey,
viemClient,
} from './utils';
import { supportedChainsMapping } from './utils/common';
import { computeAllowanceSlot, computeBalanceSlot, getChainConfig, toHexScriptPubKey, viemClient } from './utils';
import { supportedChainsMapping, resolveChainId, resolveChainName } from './utils/common';
import { getEVMToEVMWithLayerZeroStatus } from './utils/layerzero';

bitcoin.initEccLib(ecc);

Expand Down Expand Up @@ -135,8 +131,7 @@ export class LayerZeroClient {
}

async getSupportedChainsInfo(): Promise<Array<LayerZeroChainInfo>> {
const chains = await this.getChainDeployments();
const deployments = await this.getWbtcDeployments();
const [chains, deployments] = await Promise.all([this.getChainDeployments(), this.getWbtcDeployments()]);

const supportedLayerZeroChainKeys = new Set<string>();
const layerZeroKeyToViemName: Record<string, string> = {};
Expand Down Expand Up @@ -165,6 +160,26 @@ export class LayerZeroClient {
});
}

async isChainAndTokenSupportedByLayerZero(chainKey: string, token: string): Promise<boolean> {
const supportedChains = await this.getSupportedChainsInfo();

// Find the chain info matching the chainKey (case-insensitive)
const chainInfo = supportedChains.find((chain) => chain.name.toLowerCase() === chainKey.toLowerCase());

if (!chainInfo) {
return false;
}

// Token can either be the wbtc string, or the specific OFT address for wbtc on the chain
const isWbtcToken = token.toLowerCase() === 'wbtc';
const isOftAddress =
isAddress(token) && isAddress(chainInfo.oftAddress)
? isAddressEqual(getAddress(token), getAddress(chainInfo.oftAddress))
: false;

return isWbtcToken || isOftAddress;
}

async getChainId(eid: number): Promise<number | null> {
const chains = await this.getChainDeployments();

Expand Down Expand Up @@ -197,18 +212,6 @@ export class LayerZeroClient {
}
}

// Viem chain names are used to identify chains
function resolveChainId(chain: number): string {
return getChainConfig(chain).name.toLowerCase();
}

function resolveChainName(chain: number | string): string {
if (typeof chain === 'number') {
return resolveChainId(chain);
}
return chain.toLowerCase();
}

// TODO: support bob sepolia
export class LayerZeroGatewayClient extends GatewayApiClient {
private l0Client: LayerZeroClient;
Expand All @@ -223,6 +226,10 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
return this.l0Client.getSupportedChainsInfo();
}

async isChainAndTokenSupportedByLayerZero(chainKey: string, token: string): Promise<boolean> {
return this.l0Client.isChainAndTokenSupportedByLayerZero(chainKey, token);
}

/**
* @deprecated Use getSupportedChainsInfo() instead
*/
Expand Down Expand Up @@ -253,7 +260,7 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
return this.l0Client.getOftAddressForChain(chainKey);
}

async getQuote(params: GetQuoteParams<LayerZeroQuoteParamsExt>): Promise<ExecuteQuoteParams> {
async getQuote(params: GetQuoteParams<CrossChainSwapQuoteParamsExt>): Promise<ExecuteQuoteParams> {
const fromChain = typeof params.fromChain === 'number' ? resolveChainId(params.fromChain) : params.fromChain;
const toChain = typeof params.toChain === 'number' ? resolveChainId(params.toChain) : params.toChain;

Expand Down Expand Up @@ -364,11 +371,11 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
// This is 30 mins plus, therefore a large buffer is needed for values to remain valid over this period.
// 2) Bob Finality on the destination chain (destination finality).
// This is much shorter, not more than a few minutes, therefore a smaller/zero buffer can be used.
const originFinalityBuffer = params.l0OriginFinalityBuffer
? BigInt(params.l0OriginFinalityBuffer)
const originFinalityBuffer = params.originFinalityBuffer
? BigInt(params.originFinalityBuffer)
: BigInt(10000); // 100% default origin finality buffer
const destinationFinalityBuffer = params.l0DestinationFinalityBuffer
? BigInt(params.l0DestinationFinalityBuffer)
const destinationFinalityBuffer = params.destinationFinalityBuffer
? BigInt(params.destinationFinalityBuffer)
: BigInt(0); // 0% default destination finality buffer

// Getting the layer zero fee gas so we know how much we need to swap from the order
Expand Down Expand Up @@ -423,6 +430,8 @@ export class LayerZeroGatewayClient extends GatewayApiClient {

// Handle bitcoin -> l0 chain: need to add calldata
const baseQuote = await super.getQuote(params);
// change the type to OnrampWithLayerZero
baseQuote.type = GatewayOrderType.OnrampWithLayerZero;
return {
...baseQuote,
finalOutputSats: baseQuote.finalOutputSats - Number(tokensToSwapForLayerZeroFees),
Expand All @@ -436,12 +445,14 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
}

params.fromChain = bob.id;
params.l0ChainId = await this.l0Client.getChainId(dstEid);
params.destinationChainId = await this.l0Client.getChainId(dstEid);

// Handle l0 -> bitcoin: estimate bob -> bitcoin
const response = await super.getQuote(params);
// revert fromChain for handling in executeQuote
response.params.fromChain = fromChain;
// change the type to OfframpWithLayerZero
response.type = GatewayOrderType.OfframpWithLayerZero;
return response;
} else if (fromChain !== 'bitcoin' && toChain !== 'bitcoin') {
// Handle l0 -> l0 chain
Expand Down Expand Up @@ -533,7 +544,7 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
// const gasFee = await this.getL0CreateOrderGasCost(params, sendParam, sendFees, fromChain);

return {
type: GatewayOrderType.CrossChainSwap,
type: GatewayOrderType.EVMToEVMWithLayerZero,
finalOutputSats: Number(params.amount),
finalFeeSats: 0, // LayerZero sends don't have Bitcoin fees
params,
Expand Down Expand Up @@ -675,9 +686,9 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
throw error;
}
}
case GatewayOrderType.CrossChainSwap: {
case GatewayOrderType.EVMToEVMWithLayerZero: {
const { data, params } = quote;
const { oftAddress, destinationEid } = data;
const { oftAddress, destinationEid } = data as EVMToEVMWithLayerZeroQuote;

const toChain = resolveChainName(params.toChain);

Expand Down Expand Up @@ -832,15 +843,15 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
* @returns Promise resolving to the estimated gas cost in wei (as bigint)
*/
async getL0CreateOrderGasCost(
params: GetQuoteParams<LayerZeroQuoteParamsExt>,
params: GetQuoteParams<CrossChainSwapQuoteParamsExt>,
sendParams: LayerZeroSendParam,
sendFees: {
nativeFee: bigint;
lzTokenFee: bigint;
},
fromChain: string
): Promise<bigint> {
const chain = getChainConfig(params.l0ChainId ?? params.fromChain);
const chain = getChainConfig(params.destinationChainId ?? params.fromChain);
const publicClient = viemClient(chain);

if (
Expand Down Expand Up @@ -932,12 +943,12 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
}

/**
* Fetches cross-chain swap orders initiated by a given wallet.
* Fetches evm-to-evm swap orders initiated by a given wallet.
*
* @param _userAddress - Wallet address the message originated from.
* @returns Array of normalized cross-chain orders.
* @returns Array of normalized evm-to-evm orders.
*/
async getCrossChainSwapOrders(_userAddress: Address): Promise<CrossChainOrder[]> {
async getEVMToEVMWithLayerZeroOrders(_userAddress: Address): Promise<EVMToEVMWithLayerZeroOrder[]> {
const url = new URL(`https://scan.layerzero-api.com/v1/messages/wallet/${_userAddress}`);

const response = await super.safeFetch(url.toString(), undefined, 'Failed to fetch LayerZero send orders');
Expand All @@ -958,7 +969,7 @@ export class LayerZeroGatewayClient extends GatewayApiClient {

const items = json.data.filter((item) => item.destination.lzCompose.status === 'N/A');

return items.map((item): CrossChainOrder => {
return items.map((item): EVMToEVMWithLayerZeroOrder => {
const { payload, blockTimestamp, txHash: sourceTxHash } = item.source.tx;
const { txHash: destinationTxHash } = item.destination.tx;

Expand All @@ -982,7 +993,7 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
return {
amount,
timestamp: blockTimestamp,
status: getCrossChainStatus(item),
status: getEVMToEVMWithLayerZeroStatus(item),
source: {
eid: item.pathway.srcEid,
txHash: sourceTxHash,
Expand All @@ -998,20 +1009,23 @@ export class LayerZeroGatewayClient extends GatewayApiClient {
}

/**
* Retrieves all orders (onramp, offramp, and crosschain swaps) for a specific user address.
* Retrieves all orders (onramp, offramp, and evm-to-evm swaps) for a specific user address.
*
* @param userAddress The user's EVM address
* @returns Promise resolving to array of typed orders
*/
async getOrders(userAddress: Address): Promise<Array<GatewayOrder>> {
const [orders, crossChainSwapOrders] = await Promise.all([
const [orders, evmToEVMWithLayerZeroOrders] = await Promise.all([
super.getOrders(userAddress),
this.getCrossChainSwapOrders(userAddress),
this.getEVMToEVMWithLayerZeroOrders(userAddress),
]);

return [
...orders,
...crossChainSwapOrders.map((order) => ({ type: GatewayOrderType.CrossChainSwap as const, order })),
...evmToEVMWithLayerZeroOrders.map((order) => ({
type: GatewayOrderType.EVMToEVMWithLayerZero as const,
order,
})),
];
}
}
91 changes: 91 additions & 0 deletions sdk/src/gateway/swaps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Chain } from 'viem';
import {
ActionsParams,
ActionsResponse,
PathsParams,
PathsResponse,
StatusParams,
StatusResponse,
TransactionParams,
TransactionResponse,
} from './types';

export class SwapsClient {
private basePath: string;

constructor() {
this.basePath = 'https://box-api-git-zev-swaps-v2chains-decent-webapp.vercel.app/api/';
}

async getAction(params: ActionsParams): Promise<ActionsResponse> {
const url = new URL('getAction', this.basePath);

// Convert params to URLSearchParams-compatible format
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) {
return; // Skip undefined/null values
}
if (Array.isArray(value)) {
// Handle arrays (e.g., bridgeIds)
value.forEach((item) => searchParams.append(key, String(item)));
} else {
// Convert all values to strings
searchParams.append(key, String(value));
}
});

url.search = searchParams.toString();
return this.getJson(url.toString());
}

async getStatus(params: StatusParams): Promise<StatusResponse> {
const url = new URL('getStatus', this.basePath);
url.search = new URLSearchParams(params as unknown as Record<string, string>).toString();
return this.getJson(url.toString());
}

async getTransactions(params: TransactionParams): Promise<TransactionResponse> {
const url = new URL('getTransactions', this.basePath);
url.search = new URLSearchParams(params as unknown as Record<string, string>).toString();
return this.getJson(url.toString());
}

async getChainList(): Promise<Chain> {
const url = new URL('getChainList', this.basePath);
return this.getJson(url.toString());
}

async getPaths(params: PathsParams): Promise<PathsResponse> {
const url = new URL('getPaths', this.basePath);
url.search = new URLSearchParams(params as unknown as Record<string, string>).toString();
return this.getJson(url.toString());
}

private async getJson<T>(url: string): Promise<T> {
if (!process.env.NEXT_PUBLIC_SWAPS_API_KEY && !process.env.SWAPS_API_KEY)
throw new Error('process.env.NEXT_PUBLIC_SWAPS_API_KEY or process.env.SWAPS_API_KEY is missing');

Check failure on line 67 in sdk/src/gateway/swaps.ts

View workflow job for this annotation

GitHub Actions / Tests

test/cross-chain-swap.test.ts > Cross Chain Swap Tests > should get an offramp quote using the cross chain swap gateway client and execute it

Error: process.env.NEXT_PUBLIC_SWAPS_API_KEY or process.env.SWAPS_API_KEY is missing ❯ SwapsClient.getJson src/gateway/swaps.ts:67:19 ❯ SwapsClient.getAction src/gateway/swaps.ts:39:21 ❯ CrossChainSwapGatewayClient.getSwapsOfframpQuote src/gateway/cross-chain-swap.ts:329:59 ❯ CrossChainSwapGatewayClient.getQuote src/gateway/cross-chain-swap.ts:109:25 ❯ test/cross-chain-swap.test.ts:51:23

Check failure on line 67 in sdk/src/gateway/swaps.ts

View workflow job for this annotation

GitHub Actions / Tests

test/cross-chain-swap.test.ts > Cross Chain Swap Tests > should get an onramp quote using the cross chain swap gateway client and execute it

Error: process.env.NEXT_PUBLIC_SWAPS_API_KEY or process.env.SWAPS_API_KEY is missing ❯ SwapsClient.getJson src/gateway/swaps.ts:67:19 ❯ SwapsClient.getAction src/gateway/swaps.ts:39:21 ❯ CrossChainSwapGatewayClient.getSwapsOnrampQuote src/gateway/cross-chain-swap.ts:222:55 ❯ CrossChainSwapGatewayClient.getQuote src/gateway/cross-chain-swap.ts:100:25 ❯ test/cross-chain-swap.test.ts:14:23

const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-api-key': process.env.NEXT_PUBLIC_SWAPS_API_KEY || (process.env.SWAPS_API_KEY as string),
},
});
if (!response.ok) {
let errorMessage = response.statusText;
try {
const errorBody = await response.text();
if (errorBody) {
errorMessage = `${response.statusText}: ${errorBody}`;
}
} catch {
// If we can't parse the error body, use statusText
}
throw new Error(`Swaps API error (${response.status}): ${errorMessage}. URL: ${url}`);
}
return response.json() as Promise<T>;
}
}
Loading
Loading