Skip to content
Draft
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
59 changes: 59 additions & 0 deletions docs/brainstorms/2026-03-17-high-marketcap-assets-brainstorm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
date: 2026-03-17
topic: high-marketcap-assets
target_repo: actions
---

# Export High Market Cap Assets

## What We're Building

Expand the Actions SDK from a small hardcoded token list into a rich token catalog. The SDK will export ~50 popular ERC-20 asset definitions (sourced from CoinGecko top market cap on Ethereum/L2s) with verified cross-chain addresses. Developers import whichever tokens they need and pass them in their `ActionsConfig.assets.allow` list.

Simultaneously, remove the SDK's internal `SUPPORTED_TOKENS` allowlist concept. The SDK stops being the gatekeeper — the developer's `ActionsConfig` is the sole source of truth for which assets their instance supports.

## Why

SDK developers currently must hardcode every asset they need. The SDK only ships 6 tokens (ETH, WETH, USDC, MORPHO, and two demo tokens). This forces every developer to research addresses, verify cross-chain accuracy, and define `Asset` objects from scratch. Exporting a curated catalog of popular tokens makes onboarding faster and reduces the risk of address errors.

Removing the internal allowlist simplifies the SDK's architecture — there's one path for asset configuration, not two competing concepts.

## Chosen Approach

**Static constants from CoinGecko research.** One-time research using CoinGecko data to identify the top ~50 ERC-20s by market cap on Ethereum and Ethereum L2s. Hand-curate verified addresses for each supported chain (mainnet, optimism, base, unichain, worldchain + testnets where applicable). Commit as static `Asset` constants. Update via PRs when the list needs refreshing.

**Flat named exports.** Each token is a top-level named export (`import { DAI, WBTC, LINK } from '@eth-optimism/actions-sdk'`). This is tree-shakeable, ergonomic, and consistent with existing ETH/WETH/USDC exports.

## Alternatives Considered

- **Build-time script** (fetches CoinGecko, generates constants): Adds tooling complexity for a list that changes slowly. Can add later if the catalog grows unwieldy.
- **Runtime fetch**: Adds network dependency and latency at SDK init. Addresses are security-sensitive and should be reviewed, not auto-fetched.
- **Grouped exports** (by category or single collection): Less ergonomic, not tree-shakeable, inconsistent with existing export style.

## Key Decisions

- **Remove `SUPPORTED_TOKENS` internal allowlist**: The SDK no longer filters tokens internally. `ActionsConfig.assets.allow` is the sole source of truth. The `getSupportedAssets()` method and related filtering logic are removed or simplified.
- **Developers can define custom tokens**: The `Asset` type remains public. Developers can create their own `Asset` objects and pass them in config alongside or instead of SDK-provided ones.
- **Keep demo tokens**: USDC_DEMO and OP_DEMO stay as exports for the demo app.
- **Top ~50 by market cap**: Sourced from CoinGecko for tokens on Ethereum or Ethereum L2s. Each token needs verified addresses on every supported chain where it exists.
- **Static data, manually maintained**: No runtime or build-time fetching. PRs to update the list as needed.

## Success Criteria

- SDK exports ~50 popular token `Asset` definitions with accurate cross-chain addresses
- `SUPPORTED_TOKENS` internal allowlist concept is removed
- `ActionsConfig.assets.allow` is the sole mechanism for configuring which tokens an instance supports
- Developers can still define and pass custom `Asset` objects
- Demo tokens remain exported
- Existing tests updated to reflect the new architecture
- All addresses verified against CoinGecko / block explorers

## Resolved Questions

- **`AssetsConfig.block` stays**: The blocklist concept remains because runtime fetching of assets is planned for the future. Blocklist lets developers exclude specific tokens from a dynamically-fetched set.
- **Canonical bridges only**: Only include addresses from official/canonical bridge deployments. Comment on and flag popular third-party bridged versions in the code so developers are aware they exist.
- **Single file**: One `assets.ts` file with all ~50 tokens. No need to split.

## Open Questions

- None — ready for planning.
3 changes: 3 additions & 0 deletions packages/demo/backend/src/services/lend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ async function executePosition(
throw new Error(error)
}

const actions = getActions()
const assets = actions.getSupportedAssets()
const asset = resolveAsset(
tokenAddress,
marketId.chainId as SupportedChainId,
assets,
)

const positionParams = { amount, asset, marketId }
Expand Down
4 changes: 2 additions & 2 deletions packages/demo/backend/src/services/swap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ describe('Swap Service', () => {
receipt: { transactionHash: '0xtxhash' },
amountIn: 100,
amountOut: 0.5,
amountInWei: 100000000n,
amountOutWei: 500000000000000000n,
amountInRaw: 100000000n,
amountOutRaw: 500000000000000000n,
price: '0.005',
priceImpact: 0.001,
assetIn: {},
Expand Down
11 changes: 7 additions & 4 deletions packages/demo/backend/src/services/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ export async function getPrice(params: PriceParams): Promise<SwapPrice> {
const { tokenInAddress, tokenOutAddress, chainId, amountIn, amountOut } =
params
const actions = getActions()
const assetIn = resolveAsset(tokenInAddress, chainId)
const assetOut = resolveAsset(tokenOutAddress, chainId)
const assets = actions.getSupportedAssets()
const assetIn = resolveAsset(tokenInAddress, chainId, assets)
const assetOut = resolveAsset(tokenOutAddress, chainId, assets)

return await actions.swap.price({
assetIn,
Expand Down Expand Up @@ -76,8 +77,10 @@ export async function executeSwap(
throw new Error('Swap not configured for this wallet')
}

const assetIn = resolveAsset(tokenInAddress, chainId)
const assetOut = resolveAsset(tokenOutAddress, chainId)
const actions = getActions()
const assets = actions.getSupportedAssets()
const assetIn = resolveAsset(tokenInAddress, chainId, assets)
const assetOut = resolveAsset(tokenOutAddress, chainId, assets)

const result = await wallet.swap.execute({
amountIn,
Expand Down
9 changes: 2 additions & 7 deletions packages/demo/backend/src/services/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
UserOperationTransactionReceipt,
Wallet,
} from '@eth-optimism/actions-sdk'
import { getAssetAddress, getTokenBySymbol } from '@eth-optimism/actions-sdk'
import { getAssetAddress, USDC_DEMO } from '@eth-optimism/actions-sdk'
import type { User } from '@privy-io/node'
import type { Address } from 'viem'
import { encodeFunctionData, formatUnits, getAddress } from 'viem'
Expand Down Expand Up @@ -105,14 +105,9 @@ export async function mintDemoUsdcToWallet(wallet: SmartWallet): Promise<{

const amountInDecimals = BigInt(Math.floor(parseFloat('100') * 1000000))

const usdcDemoToken = getTokenBySymbol('USDC_DEMO')
if (!usdcDemoToken) {
throw new Error('USDC_DEMO token not found in supported tokens')
}

const calls = [
{
to: getAssetAddress(usdcDemoToken, baseSepolia.id),
to: getAssetAddress(USDC_DEMO, baseSepolia.id),
data: encodeFunctionData({
abi: mintableErc20Abi,
functionName: 'mint',
Expand Down
8 changes: 3 additions & 5 deletions packages/demo/backend/src/utils/assets.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import type { Asset, SupportedChainId } from '@eth-optimism/actions-sdk'
import { SUPPORTED_TOKENS } from '@eth-optimism/actions-sdk'
import type { Address } from 'viem'

/**
* Resolve a token address to an Asset from the supported tokens list
* Resolve a token address to an Asset from a provided asset list
* @throws if the token address is not found for the given chain
*/
export function resolveAsset(
tokenAddress: Address | 'native',
chainId: SupportedChainId,
assets: Asset[],
): Asset {
const asset = SUPPORTED_TOKENS.find(
(token) => token.address[chainId] === tokenAddress,
)
const asset = assets.find((token) => token.address[chainId] === tokenAddress)
if (!asset) {
throw new Error(`Asset not found for token address: ${tokenAddress}`)
}
Expand Down
18 changes: 10 additions & 8 deletions packages/demo/frontend/src/api/actionsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,14 @@ class ActionsApiClient {
})
return result.map((balance) => ({
...balance,
totalBalance: BigInt(balance.totalBalance),
chainBalances: balance.chainBalances.map((chainBalance) => ({
...chainBalance,
balance: BigInt(chainBalance.balance),
})),
}))
totalBalanceRaw: BigInt(balance.totalBalanceRaw),
chains: Object.fromEntries(
Object.entries(balance.chains).map(([chainId, chainBalance]) => [
chainId,
chainBalance ? { ...chainBalance, balanceRaw: BigInt(chainBalance.balanceRaw) } : chainBalance,
]),
),
})) as TokenBalance[]
}

async mintDemoUsdcToWallet(headers: HeadersInit = {}): Promise<{
Expand Down Expand Up @@ -258,8 +260,8 @@ class ActionsApiClient {
...result,
amountIn: Number(result.amountIn),
amountOut: Number(result.amountOut),
amountInWei: BigInt(result.amountInWei),
amountOutWei: BigInt(result.amountOutWei),
amountInRaw: BigInt(result.amountInRaw),
amountOutRaw: BigInt(result.amountOutRaw),
gasEstimate: result.gasEstimate ? BigInt(result.gasEstimate) : undefined,
} as SwapPrice
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,15 @@ const mockPosition: LendMarketPosition = {

const mockTokenBalances: TokenBalance[] = [
{
symbol: 'USDC',
totalBalance: 100000000n,
totalFormattedBalance: '100.00',
chainBalances: [
{
chainId: CHAIN_ID,
balance: 100000000n,
tokenAddress: ASSET_ADDRESS,
formattedBalance: '100.00',
asset: mockAsset,
totalBalance: 100,
totalBalanceRaw: 100000000n,
chains: {
[CHAIN_ID]: {
balance: 100,
balanceRaw: 100000000n,
},
],
},
},
]

Expand Down Expand Up @@ -212,17 +210,15 @@ describe('Activity Logging', () => {
// Return updated balance after mutation to trigger shouldLogFetch
const updatedBalances: TokenBalance[] = [
{
symbol: 'USDC',
totalBalance: 90000000n,
totalFormattedBalance: '90.00',
chainBalances: [
{
chainId: CHAIN_ID,
balance: 90000000n,
tokenAddress: ASSET_ADDRESS,
formattedBalance: '90.00',
asset: mockAsset,
totalBalance: 90,
totalBalanceRaw: 90000000n,
chains: {
[CHAIN_ID]: {
balance: 90,
balanceRaw: 90000000n,
},
],
},
},
]
const updatedPosition: LendMarketPosition = {
Expand Down
16 changes: 10 additions & 6 deletions packages/demo/frontend/src/hooks/useSwapAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,19 @@ export function useSwapAssets({
const seen = new Set<string>()
return tokenBalances
.map((balance): SwapAsset | null => {
const asset = assetMap.get(balance.symbol)
if (!asset || seen.has(balance.symbol)) return null
seen.add(balance.symbol)
const symbol = balance.asset.metadata.symbol
const asset = assetMap.get(symbol)
if (!asset || seen.has(symbol)) return null
seen.add(symbol)

// Find first chain with a balance
const firstChainId = Object.keys(balance.chains)[0]

return {
asset,
logo: getAssetLogo(balance.symbol),
balance: balance.totalFormattedBalance,
chainId: balance.chainBalances[0]?.chainId || 84532,
logo: getAssetLogo(symbol),
balance: balance.totalBalance.toString(),
chainId: firstChainId ? (Number(firstChainId) as SupportedChainId) : (84532 as SupportedChainId),
}
})
.filter((item): item is SwapAsset => item !== null)
Expand Down
46 changes: 17 additions & 29 deletions packages/demo/frontend/src/utils/balanceMatching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,64 +25,52 @@ export function matchAssetBalance({
return '0.00'
}

let assetToken: (typeof allTokenBalances)[0] | undefined
let chainBalance: (typeof allTokenBalances)[0]['chainBalances'][0] | undefined
let assetToken: TokenBalance | undefined
let chainBalance: { balance: number; balanceRaw: bigint } | undefined

if (marketData?.assetAddress && marketData?.marketId?.chainId) {
const targetChainId = marketData.marketId.chainId

// For ETH markets, match by symbol (native token has no address)
// For ERC20 tokens, match by address and chainId
// For ETH markets, match by asset type (native token has no address)
// For ERC20 tokens, match by address on the target chain
if (isEthSymbol(selectedAssetSymbol)) {
assetToken = allTokenBalances.find((token) => isEthSymbol(token.symbol))
assetToken = allTokenBalances.find((token) => isEthSymbol(token.asset.metadata.symbol))
} else {
const targetAddress = marketData.assetAddress.toLowerCase()
for (const token of allTokenBalances) {
const matchingChainBalance = token.chainBalances.find(
(cb) =>
cb.tokenAddress.toLowerCase() === targetAddress &&
cb.chainId === targetChainId,
)
if (matchingChainBalance) {
const tokenAddr = token.asset.address[targetChainId]
if (tokenAddr && tokenAddr.toLowerCase() === targetAddress) {
assetToken = token
chainBalance = matchingChainBalance
chainBalance = token.chains[targetChainId]
break
}
}
}

// Get chain-specific balance if we found the token
// Get chain-specific balance if we found the token but not the chain balance
if (assetToken && !chainBalance) {
chainBalance = assetToken.chainBalances.find(
(cb) => cb.chainId === targetChainId,
)
chainBalance = assetToken.chains[targetChainId]
}
} else {
// Fallback to symbol matching (less precise)
assetToken = allTokenBalances.find(
(token) => token.symbol === selectedAssetSymbol,
(token) => token.asset.metadata.symbol === selectedAssetSymbol,
)
}

const isEth = isEthSymbol(selectedAssetSymbol)
const displayPrecision = isEth ? 4 : 2
const precisionMultiplier = Math.pow(10, displayPrecision)

if (assetToken && chainBalance && BigInt(chainBalance.balance) > 0n) {
// Use the specific chain balance
const decimals = selectedAssetSymbol.includes('USDC') ? 6 : 18
const balance =
parseFloat(`${chainBalance.balance}`) / Math.pow(10, decimals)
if (assetToken && chainBalance && chainBalance.balanceRaw > 0n) {
// Use the specific chain balance (already human-readable)
const flooredBalance =
Math.floor(balance * precisionMultiplier) / precisionMultiplier
Math.floor(chainBalance.balance * precisionMultiplier) / precisionMultiplier
return flooredBalance.toFixed(displayPrecision)
} else if (assetToken && BigInt(assetToken.totalBalance) > 0n) {
// Fallback to total balance if no specific chain balance
const decimals = selectedAssetSymbol.includes('USDC') ? 6 : 18
const balance =
parseFloat(`${assetToken.totalBalance}`) / Math.pow(10, decimals)
} else if (assetToken && assetToken.totalBalanceRaw > 0n) {
// Fallback to total balance (already human-readable)
const flooredBalance =
Math.floor(balance * precisionMultiplier) / precisionMultiplier
Math.floor(assetToken.totalBalance * precisionMultiplier) / precisionMultiplier
return flooredBalance.toFixed(displayPrecision)
} else {
return isEth ? '0.0000' : '0.00'
Expand Down
Loading