Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
82 changes: 82 additions & 0 deletions apps/web/docs/token-url-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Token URL System

## Overview

The application now uses token symbols in URLs instead of asset IDs, making URLs more user-friendly and readable. For tokens with duplicate symbols (like different USDC types), unique identifiers are generated.

## URL Format

### Before (using asset IDs)
```
https://localhost:3000/?from=22222052&to=12345678
```

### After (using token symbols)
```
https://localhost:3000/?from=DOT&to=USDC
```

For tokens with duplicate symbols:
```
https://localhost:3000/?from=USDC-0002&to=USDC-0003
```

## How It Works

### 1. Token Identifier Generation

- **Unique symbols**: Use the symbol directly (e.g., `DOT`, `USDT`)
- **Duplicate symbols**: Append asset ID suffix (e.g., `USDC-0002`, `USDC-0003`)

### 2. Internal Mapping

The system maintains two mappings:
- `tokenIdentifierMap`: Maps token identifiers to asset IDs
- `assetIdToIdentifierMap`: Maps asset IDs to token identifiers

### 3. URL Parameter Handling

- URLs use token identifiers (symbols)
- Internal operations use asset IDs
- Automatic conversion between the two formats

## Key Functions

### `createTokenIdentifierMap(assets)`
Creates a mapping from token identifiers to asset IDs, handling duplicate symbols.

### `findAssetByIdentifier(assets, identifier)`
Finds an asset by its identifier (symbol or symbol-hash).

### `getAssetIdentifier(assets, assetId)`
Gets the identifier for a given asset ID.

## Backward Compatibility

The system maintains backward compatibility by:
- Supporting both symbol and asset ID lookups
- Case-insensitive symbol matching
- Graceful fallback to symbol matching if identifier lookup fails

## Example Usage

```typescript
// URL shows: https://localhost:3000/?from=DOT&to=USDC-0002
// Internal mapping: DOT -> asset_id_1, USDC-0002 -> asset_id_2

// When user selects a token
setInputToken(token); // token.id = "asset_id_1"
// URL updates to: ?from=DOT

// When URL changes
// fromTokenIdentifier = "DOT"
// findAssetByIdentifier() returns asset with id "asset_id_1"
```

## Benefits

1. **User-friendly URLs**: Easy to read and understand
2. **Shareable links**: Users can easily share specific token pairs
3. **SEO friendly**: URLs contain meaningful token names
4. **Backward compatible**: Old URLs still work
5. **Handles duplicates**: Unique identifiers for tokens with same symbol
163 changes: 118 additions & 45 deletions apps/web/src/components/swap/hooks/useSwapTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import type { AssetWithId } from '@/lib/api';
import type { TokenInfo } from '@/components/swap/types';
import { toast } from 'react-hot-toast';
import { useFromTokenState, useToTokenState } from './utils/queryParams';
import {
findAssetByIdentifier,
getAssetIdentifier,
createTokenIdentifierMap,
createAssetIdToIdentifierMap
} from '@/lib/tokenUtils';

export function useSwapTokens() {
const [assets, setAssets] = useState<AssetWithId[]>([]);
const [isInitialized, setIsInitialized] = useState(false);

// Use centralized query params configuration
const [fromSymbol, setFromSymbol] = useFromTokenState();
const [toSymbol, setToSymbol] = useToTokenState();
// Use centralized query params configuration (now using token identifiers)
const [fromTokenIdentifier, setFromTokenIdentifier] = useFromTokenState();
const [toTokenIdentifier, setToTokenIdentifier] = useToTokenState();

// Fetch assets only once during initialization
useEffect(() => {
Expand All @@ -20,71 +26,138 @@ export function useSwapTokens() {
const fetchAssets = async () => {
try {
const fetchedAssets = await api.assets.getAll();
setAssets(fetchedAssets);
setAssets(fetchedAssets || []);
setIsInitialized(true);
} catch (error) {
console.error('Failed to fetch assets:', error);
toast.error('Failed to load assets');
setIsInitialized(true); // Mark as initialized even on error to prevent infinite retries
}
};

fetchAssets();
}, [isInitialized]);

// Convert symbols to token objects
// Set default tokens if none are selected and assets are loaded
useEffect(() => {
if (assets.length > 0 && isInitialized && (!fromTokenIdentifier || !toTokenIdentifier)) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: the condition !fromTokenIdentifier || !toTokenIdentifier will trigger default selection even when empty strings are set in URL params, which may override user's explicit empty selections

// Find some default tokens (first two available tokens)
const availableTokens = assets.filter(asset => asset && asset.metadata && asset.metadata.symbol);

if (availableTokens.length >= 2) {
if (!fromTokenIdentifier) {
const defaultFrom = availableTokens[0];
const fromIdentifier = getAssetIdentifier(assets, defaultFrom.id);
if (fromIdentifier) {
setFromTokenIdentifier(fromIdentifier);
}
}

if (!toTokenIdentifier) {
const defaultTo = availableTokens[1];
const toIdentifier = getAssetIdentifier(assets, defaultTo.id);
if (toIdentifier) {
setToTokenIdentifier(toIdentifier);
}
}
}
}
}, [assets, isInitialized, fromTokenIdentifier, toTokenIdentifier, setFromTokenIdentifier, setToTokenIdentifier]);

// Create mappings for efficient lookups
const tokenIdentifierMap = useMemo(() =>
createTokenIdentifierMap(assets), [assets]
);

const assetIdToIdentifierMap = useMemo(() =>
createAssetIdToIdentifierMap(assets), [assets]
);

// Convert token identifiers to token objects
const inputToken = useMemo(() => {
if (!assets.length || !fromSymbol) return null;

const asset = assets.find(asset =>
asset.metadata.symbol.toUpperCase() === fromSymbol.toUpperCase()
);

if (!asset) return null;
if (!assets.length || !fromTokenIdentifier) return null;

return {
id: asset.id,
name: asset.metadata.name,
symbol: asset.metadata.symbol,
icon: asset.metadata.symbol.charAt(0),
decimals: asset.metadata.decimals
};
}, [assets, fromSymbol]);
try {
// Find asset by identifier (symbol or symbol-hash)
const asset = findAssetByIdentifier(assets, fromTokenIdentifier);

if (!asset || !asset.metadata) return null;

return {
id: asset.id,
name: asset.metadata.name || '',
symbol: asset.metadata.symbol || '',
icon: (asset.metadata.symbol || '').charAt(0) || '?',
decimals: asset.metadata.decimals || 0
};
} catch (error) {
console.error('Error creating input token:', error);
return null;
}
}, [assets, fromTokenIdentifier]);

const outputToken = useMemo(() => {
if (!assets.length || !toSymbol) return null;

const asset = assets.find(asset =>
asset.metadata.symbol.toUpperCase() === toSymbol.toUpperCase()
);

if (!asset) return null;
if (!assets.length || !toTokenIdentifier) return null;

return {
id: asset.id,
name: asset.metadata.name,
symbol: asset.metadata.symbol,
icon: asset.metadata.symbol.charAt(0),
decimals: asset.metadata.decimals
};
}, [assets, toSymbol]);
try {
// Find asset by identifier (symbol or symbol-hash)
const asset = findAssetByIdentifier(assets, toTokenIdentifier);

if (!asset || !asset.metadata) return null;

return {
id: asset.id,
name: asset.metadata.name || '',
symbol: asset.metadata.symbol || '',
icon: (asset.metadata.symbol || '').charAt(0) || '?',
decimals: asset.metadata.decimals || 0
};
} catch (error) {
console.error('Error creating output token:', error);
return null;
}
}, [assets, toTokenIdentifier]);

// Token selection handlers that update URL automatically
// Token selection handlers that update URL with token identifiers
const setInputToken = (token: TokenInfo) => {
setFromSymbol(token.symbol);
try {
const identifier = assetIdToIdentifierMap.get(token.id);
if (identifier) {
setFromTokenIdentifier(identifier);
}
} catch (error) {
console.error('Error setting input token:', error);
}
};

const setOutputToken = (token: TokenInfo) => {
setToSymbol(token.symbol);
try {
const identifier = assetIdToIdentifierMap.get(token.id);
if (identifier) {
setToTokenIdentifier(identifier);
}
} catch (error) {
console.error('Error setting output token:', error);
}
};

// Convert assets to tokens for selection
const tokens = useMemo(() => assets.map(asset => ({
id: asset.id,
name: asset.metadata.name,
symbol: asset.metadata.symbol,
icon: asset.metadata.symbol.charAt(0),
decimals: asset.metadata.decimals
})), [assets]);
const tokens = useMemo(() => {
try {
return assets
.filter(asset => asset && asset.metadata && asset.metadata.symbol)
.map(asset => ({
id: asset.id,
name: asset.metadata.name || '',
symbol: asset.metadata.symbol || '',
icon: (asset.metadata.symbol || '').charAt(0) || '?',
decimals: asset.metadata.decimals || 0
}));
} catch (error) {
console.error('Error creating tokens array:', error);
return [];
}
}, [assets]);

return {
inputToken,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/swap/hooks/utils/queryParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ import { useQueryState, parseAsString, parseAsInteger } from 'nuqs'
* This follows nuqs best practices for organization and type safety
*/
export const swapQueryParams = {
// Token selection
// Token selection (using token symbols/identifiers)
useFromTokenState: () => useQueryState(
'from',
parseAsString.withDefault('DOT').withOptions({
parseAsString.withDefault('').withOptions({
shallow: false, // Trigger server re-render if needed
history: 'replace' // Don't create history entries for token changes
})
),

useToTokenState: () => useQueryState(
'to',
parseAsString.withDefault('USDT').withOptions({
parseAsString.withDefault('').withOptions({
shallow: false,
history: 'replace'
})
Expand Down
Loading