Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
78 changes: 78 additions & 0 deletions src/lib/swap/vdlSwapConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
export const VDL_CHAIN_ID = 'beezee-1';
export const VDL_DENOM = 'factory/bze13gzq40che93tgfm9kzmkpjamah5nj0j73pyhqk/uvdl';
export const VDL_SWAP_TITLE = 'Buy / Sell VDL';

/**
* @skip-go/client appends `/v2/...` to this origin.
* Skip allows browser CORS, so the wallet calls it directly.
* Do not add `/v2` here, and do not point this at a hostname with no DNS record.
*/
export const SKIP_GO_API_URL = 'https://api.skip.build';

export type VdlSwapMode = 'buy' | 'sell';

export interface SkipDefaultRoute {
srcChainId?: string;
srcAssetDenom?: string;
destChainId?: string;
destAssetDenom?: string;
srcLocked?: boolean;
destLocked?: boolean;
}

export interface SkipAssetFilter {
source?: Record<string, string[] | undefined>;
destination?: Record<string, string[] | undefined>;
}

export interface VdlSwapConfig {
defaultRoute: SkipDefaultRoute;
filter: SkipAssetFilter;
filterOut: SkipAssetFilter;
}

const vdlOnly: Record<string, string[] | undefined> = {
[VDL_CHAIN_ID]: [VDL_DENOM],
};

export function vdlSwapCopy(mode: VdlSwapMode): string {
switch (mode) {
case 'buy':
return 'Pay with any asset. Receive VDL.';
case 'sell':
return 'Pay with VDL. Receive any asset.';
default: {
const unreachable: never = mode;
return unreachable;
}
}
}

export function vdlSwapConfig(mode: VdlSwapMode): VdlSwapConfig {
switch (mode) {
case 'buy':
return {
defaultRoute: {
destChainId: VDL_CHAIN_ID,
destAssetDenom: VDL_DENOM,
destLocked: true,
},
filter: { destination: vdlOnly },
filterOut: { source: vdlOnly },
};
case 'sell':
return {
defaultRoute: {
srcChainId: VDL_CHAIN_ID,
srcAssetDenom: VDL_DENOM,
srcLocked: true,
},
filter: { source: vdlOnly },
filterOut: { destination: vdlOnly },
};
default: {
const unreachable: never = mode;
return unreachable;
}
}
}
5 changes: 3 additions & 2 deletions src/popup/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import NetworkManagerModal from '../components/NetworkManagerModal';
import IBCTransferModal from '../components/IBCTransferModal';
import { useNetworkStore } from '@/store/networkStore';
import { fetchIBCConnections, IBCChannel } from '@/lib/cosmos/ibc-connections';
import { VDL_SWAP_TITLE } from '@/lib/swap/vdlSwapConfig';

interface DashboardProps {
onNavigateToStaking?: () => void;
Expand Down Expand Up @@ -803,7 +804,7 @@ const Dashboard: React.FC<DashboardProps> = ({
{/* Scrollable Content */}
<Box flex={1} overflowY="auto" px={5} py={5} pb={14}>
<VStack spacing={5} align="stretch">
{/* Cross Chain Swaps Button */}
{/* Buy / Sell VDL */}
<Button
flex={1}
variant="outline"
Expand All @@ -814,7 +815,7 @@ const Dashboard: React.FC<DashboardProps> = ({
onClick={onNavigateToSwap}
>
<Text fontSize="lg" margin={2}>
Cross-Chain Swaps
{VDL_SWAP_TITLE}
</Text>
</Button>

Expand Down
99 changes: 47 additions & 52 deletions src/popup/pages/Swap.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import React, { useMemo, useCallback, useRef } from 'react';
import { Box, HStack, Text, useToast, IconButton } from '@chakra-ui/react';
import React, { useMemo, useCallback, useRef, useState } from 'react';
import { Box, HStack, Text, useToast, IconButton, Button } from '@chakra-ui/react';
import { ArrowBackIcon } from '@chakra-ui/icons';
import { Widget } from '@skip-go/widget';
import { useWalletStore } from '@/store/walletStore';
import { useNetworkStore } from '@/store/networkStore';
import {
networkRegistry,
isCosmosNetwork,
isEvmNetwork,
isSvmNetwork,
getHealthyEndpoint,
} from '@/lib/networks';
import type { CosmosNetworkConfig, SvmNetworkConfig } from '@/lib/networks';
Expand All @@ -17,6 +15,13 @@ import { fromBech32 } from '@cosmjs/encoding';
import { createWalletClient, defineChain, http, type Hex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { Keypair, PublicKey } from '@solana/web3.js';
import {
SKIP_GO_API_URL,
vdlSwapConfig,
vdlSwapCopy,
VDL_SWAP_TITLE,
type VdlSwapMode,
} from '@/lib/swap/vdlSwapConfig';

/**
* Vidulum affiliate fee configuration.
Expand All @@ -25,7 +30,6 @@ import { Keypair, PublicKey } from '@solana/web3.js';
*/
const VIDULUM_FEE_BPS = import.meta.env.VIDULUM_FEE_BPS || '';
const VIDULUM_FEE_BASE_ADDRESS = import.meta.env.VIDULUM_FEE_BASE_ADDRESS || '';
const SKIP_API_URL = 'https://api.vidulum.app/skip/api/skip/v2';

const skipSvmChainIdByNetworkId: Record<string, string> = {
'solana-mainnet': 'solana',
Expand All @@ -51,6 +55,8 @@ const SwapPage: React.FC<SwapPageProps> = ({ onBack }) => {
const { getEnabledNetworks } = useNetworkStore();
const toast = useToast();
const lastErrorToastRef = useRef<{ message: string; timestamp: number } | null>(null);
const [mode, setMode] = useState<VdlSwapMode>('buy');
const swapConfig = useMemo(() => vdlSwapConfig(mode), [mode]);

/**
* Build connected addresses map for ALL Cosmos chains in the registry.
Expand Down Expand Up @@ -283,35 +289,6 @@ const SwapPage: React.FC<SwapPageProps> = ({ onBack }) => {
return adapterLike as any;
}, []);

/**
* Build the source filter so the widget only shows chains/assets
* the user has enabled in their network preferences.
*/
const sourceFilter = useMemo<Record<string, string[] | undefined>>(() => {
const filter: Record<string, string[] | undefined> = {};
const enabledNetworks = getEnabledNetworks();

for (const network of enabledNetworks) {
if (isCosmosNetwork(network)) {
if (connectedAddresses[network.id]) {
filter[network.id] = undefined;
}
} else if (isEvmNetwork(network)) {
const skipChainId = String(network.chainId);
if (connectedAddresses[skipChainId]) {
filter[skipChainId] = undefined;
}
} else if (isSvmNetwork(network)) {
const skipChainId = getSkipSvmChainId(network);
if (connectedAddresses[skipChainId]) {
filter[skipChainId] = undefined;
}
}
}

return filter;
}, [getEnabledNetworks, connectedAddresses]);

/**
* Build chainIdsToAffiliates for Vidulum's fee collection.
* Derives the fee recipient address on each enabled Cosmos chain from the
Expand Down Expand Up @@ -550,7 +527,7 @@ const SwapPage: React.FC<SwapPageProps> = ({ onBack }) => {
<Box h="full" bg="#0a0a0a" color="white" display="flex" flexDirection="column">
{/* Header */}
<Box p={4} borderBottom="1px" borderColor="#2a2a2a">
<HStack>
<HStack mb={3}>
<IconButton
aria-label="Back"
icon={<ArrowBackIcon />}
Expand All @@ -560,38 +537,56 @@ const SwapPage: React.FC<SwapPageProps> = ({ onBack }) => {
onClick={onBack}
/>
<Text fontSize="xl" fontWeight="bold">
Cross-Chain Swap
{VDL_SWAP_TITLE}
</Text>
</HStack>
<HStack spacing={2} mb={2}>
<Button
size="sm"
bg={mode === 'buy' ? '#00e5ff' : 'transparent'}
color={mode === 'buy' ? '#0a0a0a' : 'gray.300'}
borderWidth="1px"
borderColor={mode === 'buy' ? '#00e5ff' : 'gray.600'}
_hover={{ bg: mode === 'buy' ? '#00e5ff' : 'whiteAlpha.100' }}
onClick={() => setMode('buy')}
>
Buy
</Button>
<Button
size="sm"
bg={mode === 'sell' ? '#00e5ff' : 'transparent'}
color={mode === 'sell' ? '#0a0a0a' : 'gray.300'}
borderWidth="1px"
borderColor={mode === 'sell' ? '#00e5ff' : 'gray.600'}
_hover={{ bg: mode === 'sell' ? '#00e5ff' : 'whiteAlpha.100' }}
onClick={() => setMode('sell')}
>
Sell
</Button>
</HStack>
<Text fontSize="sm" color="gray.400">
{vdlSwapCopy(mode)}
</Text>
</Box>

{/* Skip Go Widget */}
<Box flex="1" overflow="auto" p={2}>
<Box w="100%" maxW="480px" mx="auto">
<Widget
apiUrl={SKIP_API_URL}
// --- Default route: start with Osmosis OSMO ---
defaultRoute={{
srcChainId: 'osmosis-1',
srcAssetDenom: 'uosmo',
}}
// --- Route configuration ---
key={mode}
apiUrl={SKIP_GO_API_URL}
defaultRoute={swapConfig.defaultRoute}
routeConfig={{
experimentalFeatures: ['hyperlane', 'cctp', 'eureka'],
allowMultiTx: true,
allowUnsafe: false,
// Workaround for a current @skip-go/widget DOM nesting warning in
// RoutePreferenceSelector (div rendered inside p).
// Keeping this false avoids rendering that faulty section.
// Workaround for a @skip-go/widget DOM nesting warning in RoutePreferenceSelector
// (div rendered inside p); keeping goFast false avoids rendering that section.
goFast: false,
}}
// --- Sign all txs upfront for multi-tx routes ---
batchSignTxs={true}
// --- Only show chains the user has enabled ---
filter={{
source: sourceFilter,
}}
// --- Swap settings ---
filter={swapConfig.filter}
filterOut={swapConfig.filterOut}
settings={{
slippage: 0.5,
}}
Expand Down
87 changes: 87 additions & 0 deletions tests/lib/swap/vdlSwapConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
SKIP_GO_API_URL,
vdlSwapConfig,
vdlSwapCopy,
VDL_CHAIN_ID,
VDL_DENOM,
VDL_SWAP_TITLE,
} from '@/lib/swap/vdlSwapConfig';

describe('vdlSwapConfig', () => {
it('buys VDL on BeeZee and leaves the pay-with side open', () => {
const config = vdlSwapConfig('buy');

expect(config.defaultRoute).toEqual({
destChainId: VDL_CHAIN_ID,
destAssetDenom: VDL_DENOM,
destLocked: true,
});
expect(config.defaultRoute.srcLocked).toBeUndefined();
expect(config.defaultRoute.srcChainId).toBeUndefined();
expect(config.defaultRoute.srcAssetDenom).toBeUndefined();
expect(config.defaultRoute).not.toHaveProperty('amountIn');
expect(config.defaultRoute).not.toHaveProperty('amountOut');

expect(config.filter.destination).toEqual({
[VDL_CHAIN_ID]: [VDL_DENOM],
});
expect(config.filter.source).toBeUndefined();

expect(config.filterOut.source).toEqual({
[VDL_CHAIN_ID]: [VDL_DENOM],
});
expect(config.filterOut.destination).toBeUndefined();
});

it('sells BeeZee VDL and leaves the receive side open', () => {
const config = vdlSwapConfig('sell');

expect(config.defaultRoute).toEqual({
srcChainId: VDL_CHAIN_ID,
srcAssetDenom: VDL_DENOM,
srcLocked: true,
});
expect(config.defaultRoute.destLocked).toBeUndefined();
expect(config.defaultRoute.destChainId).toBeUndefined();
expect(config.defaultRoute.destAssetDenom).toBeUndefined();
expect(config.defaultRoute).not.toHaveProperty('amountIn');
expect(config.defaultRoute).not.toHaveProperty('amountOut');

expect(config.filter.source).toEqual({
[VDL_CHAIN_ID]: [VDL_DENOM],
});
expect(config.filter.destination).toBeUndefined();

expect(config.filterOut.destination).toEqual({
[VDL_CHAIN_ID]: [VDL_DENOM],
});
expect(config.filterOut.source).toBeUndefined();
});

it('does not lock the same side for buy and sell', () => {
const buy = vdlSwapConfig('buy');
const sell = vdlSwapConfig('sell');

expect(buy.filter.destination).toBeDefined();
expect(buy.filter.source).toBeUndefined();
expect(sell.filter.source).toBeDefined();
expect(sell.filter.destination).toBeUndefined();
});

it('uses the BeeZee factory denom', () => {
expect(VDL_CHAIN_ID).toBe('beezee-1');
expect(VDL_DENOM).toBe('factory/bze13gzq40che93tgfm9kzmkpjamah5nj0j73pyhqk/uvdl');
});

it('returns the page copy', () => {
expect(VDL_SWAP_TITLE).toBe('Buy / Sell VDL');
expect(vdlSwapCopy('buy')).toBe('Pay with any asset. Receive VDL.');
expect(vdlSwapCopy('sell')).toBe('Pay with VDL. Receive any asset.');
});

it('calls Skip directly so the client can append /v2', () => {
expect(SKIP_GO_API_URL).toBe('https://api.skip.build');
expect(SKIP_GO_API_URL.endsWith('/v2')).toBe(false);
expect(SKIP_GO_API_URL.includes('api.vidulum.app')).toBe(false);
});
});
Loading