Skip to content
Closed
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
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
164 changes: 119 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,139 @@ 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
// Only set defaults when parameters are null (not present in URL), not when they are empty strings
useEffect(() => {
if (assets.length > 0 && isInitialized) {
const availableTokens = assets.filter(asset => asset && asset.metadata && asset.metadata.symbol);

if (availableTokens.length >= 2) {
// Only set defaults if parameters are null (missing from URL)
if (fromTokenIdentifier === null) {
const defaultFrom = availableTokens[0];
const fromIdentifier = getAssetIdentifier(assets, defaultFrom.id);
if (fromIdentifier) {
setFromTokenIdentifier(fromIdentifier);
}
}

if (toTokenIdentifier === null) {
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.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.withOptions({
shallow: false,
history: 'replace'
})
Expand Down
Loading