|
| 1 | +import { FunctionOptions, FunctionReturn, toResult, EVM, EvmChain } from '@heyanon/sdk'; |
| 2 | +import { PendleAsset, PendleClient } from '../helpers/client'; |
| 3 | +import { supportedChains } from '../constants'; |
| 4 | +import { filterActiveAssets } from '../helpers/tokens'; |
| 5 | +import { toTitleCase } from '../helpers/format'; |
| 6 | + |
| 7 | +interface Props { |
| 8 | + chainName: string; |
| 9 | + /** type of the token */ |
| 10 | + pendleTokenType: `${'PT' | 'YT' | 'SY' | 'LP'}`; |
| 11 | + /** name of the market e.g. wstETH, USDe */ |
| 12 | + pendleTokenName: string; |
| 13 | + /** short expiry e.g. 26MAR2026 */ |
| 14 | + shortExpiry: string | null; |
| 15 | +} |
| 16 | + |
| 17 | +const { getChainFromName } = EVM.utils; |
| 18 | + |
| 19 | +export async function getPendleTokenAddressFromTypeAndName( |
| 20 | + { chainName, pendleTokenType, pendleTokenName, shortExpiry }: Props, |
| 21 | + _options: FunctionOptions, |
| 22 | +): Promise<FunctionReturn> { |
| 23 | + // Validation |
| 24 | + const chainId = getChainFromName(chainName as EvmChain); |
| 25 | + if (!chainId) return toResult(`Unsupported chain name: ${toTitleCase(chainName)}`, true); |
| 26 | + if (!supportedChains.includes(chainId)) return toResult(`Pendle is not supported on ${toTitleCase(chainName)}`, true); |
| 27 | + |
| 28 | + // Make sure the token type is in the correct format |
| 29 | + if (!['PT', 'YT', 'SY', 'LP'].includes(pendleTokenType)) { |
| 30 | + return toResult(`Invalid name for a Pendle token, must be "PT", "YT", "SY" or "LP": ${pendleTokenType}`, true); |
| 31 | + } |
| 32 | + |
| 33 | + // Make sure the token name is not empty |
| 34 | + if (!pendleTokenName) { |
| 35 | + return toResult(`Pendle token name incomplete. Please specify both the type (e.g. "PT", "YT", "SY", "LP") and the token name (e.g. "wstETH", "USDe")`, true); |
| 36 | + } |
| 37 | + |
| 38 | + // Label to refer to the token |
| 39 | + const tokenLabelNoExpiry = `${pendleTokenType} ${pendleTokenName}`; |
| 40 | + |
| 41 | + // Get all Pendle assets from the API. |
| 42 | + // |
| 43 | + // Please note that with respect to the tool definition, Pendle token names |
| 44 | + // from the API include both the underlying token symbol (e.g. wstETH) and, in |
| 45 | + // parentheses, the maturation token (e.g. stETH), that is, the token to which |
| 46 | + // the PT token will convert to at expiration. For example, the name of the |
| 47 | + // principal token for the wstETH market is "PT wstETH (stETH)". |
| 48 | + // |
| 49 | + // Also worth noting is that Pendle token symbols contain only the symbol |
| 50 | + // of the maturation token (rather than the underlying token) and also include |
| 51 | + // the expiry date. For example, the symbol of the principal token for the |
| 52 | + // wstETH market is "PT-stETH-25DEC2025". |
| 53 | + const pendleClient = new PendleClient(); |
| 54 | + let assets = await pendleClient.getAllAssets(chainId); |
| 55 | + |
| 56 | + // If no expiry date is specified, consider only active markets |
| 57 | + if (!shortExpiry) { |
| 58 | + assets = filterActiveAssets(assets, true); |
| 59 | + } |
| 60 | + |
| 61 | + // Do a first search by name/symbol |
| 62 | + let byLabel; |
| 63 | + if (pendleTokenType !== 'SY') { |
| 64 | + // For PT, YT and LP tokens, we do a partial search by NAME. It |
| 65 | + // is important to use the name instead of the symbol to reflect |
| 66 | + // Pendle UI, where the name (e.g. PT wstETH (stETH)) is shown |
| 67 | + // rather than the symbol (e.g. PT-stETH). Screenshot > https://d.pr/i/kM2EFC |
| 68 | + const query = `${pendleTokenType} ${pendleTokenName}`; |
| 69 | + byLabel = assets.filter((a) => a.name.toLowerCase().includes(query.toLowerCase())); |
| 70 | + } else { |
| 71 | + // For SY tokens, we do a partial search by SYMBOL, because the token |
| 72 | + // name of SY tokens is chosen to be just the name of the underlying token |
| 73 | + // to avoid confusion on the Pendle UI. The symbol, however, does include |
| 74 | + // the "SY-" prefix which we can use to disambiguate. |
| 75 | + const query = `SY-${pendleTokenName}`; |
| 76 | + byLabel = assets.filter((a) => a.symbol.toLowerCase().includes(query.toLowerCase())); |
| 77 | + } |
| 78 | + |
| 79 | + // If no results are found, let's try searching by market name instead |
| 80 | + if (byLabel.length === 0) { |
| 81 | + // Filter markets using the token name provided by the user |
| 82 | + let markets = await pendleClient.getActiveMarkets(chainId); |
| 83 | + markets = markets.filter((m) => m.name.toLowerCase().includes(pendleTokenName.toLowerCase())); |
| 84 | + if (markets.length === 0) { |
| 85 | + return toResult(`Could not find the Pendle asset '${tokenLabelNoExpiry}' on ${toTitleCase(chainName)}`); |
| 86 | + } |
| 87 | + // Map markets to Pendle assets of the type requested by the user |
| 88 | + const byMarketName: (PendleAsset | null)[] = markets.map((m) => { |
| 89 | + let address = ''; |
| 90 | + const tokenType = pendleTokenType.toLowerCase() as 'pt' | 'yt' | 'sy' | 'lp'; |
| 91 | + if (tokenType === 'lp') { |
| 92 | + address = m.address; |
| 93 | + } else { |
| 94 | + address = m[tokenType]; |
| 95 | + address = address.split('-')[1] as `0x${string}`; |
| 96 | + } |
| 97 | + if (!address) { |
| 98 | + return null; |
| 99 | + } |
| 100 | + const asset = assets.find((a) => a.address === address); |
| 101 | + return asset || null; |
| 102 | + }); |
| 103 | + // Filter out null values |
| 104 | + const byMarketNameFiltered = byMarketName.filter(Boolean) as PendleAsset[]; |
| 105 | + // Suggest the user the possible matches |
| 106 | + return toResult( |
| 107 | + [ |
| 108 | + `Could not find an exact match for token '${tokenLabelNoExpiry}', maybe you meant ${byMarketNameFiltered.length > 1 ? 'one of these tokens' : 'this token'}?`, |
| 109 | + byMarketNameFiltered.map((a) => formatPendleAsset(a, chainName, ' - ')).join('\n'), |
| 110 | + ].join('\n'), |
| 111 | + ); |
| 112 | + } |
| 113 | + |
| 114 | + // Case where no expiry date was specified |
| 115 | + if (!shortExpiry) { |
| 116 | + // If there is only one result, return that result |
| 117 | + if (byLabel.length === 1) { |
| 118 | + return toResult(formatPendleAsset(byLabel[0], chainName)); |
| 119 | + } else { |
| 120 | + // If there is more than one result, return all of them |
| 121 | + // warning the user that there are multiple results |
| 122 | + return toResult([`Found multiple Pendle assets matching '${tokenLabelNoExpiry}':`, byLabel.map((a) => formatPendleAsset(a, chainName, ' - ')).join('\n')].join('\n')); |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // Further filter by expiry date. We use the symbol |
| 127 | + // to filter as it contains the expiry date in short format |
| 128 | + // e.g. PT-stETH-30MAR2026 |
| 129 | + const byLabelAndExpiry = byLabel.filter((a) => a.symbol.toLowerCase().includes(shortExpiry.toLowerCase())); |
| 130 | + |
| 131 | + // If there is a single match, return it |
| 132 | + if (byLabelAndExpiry.length === 1) { |
| 133 | + return toResult(formatPendleAsset(byLabelAndExpiry[0], chainName)); |
| 134 | + } else if (byLabelAndExpiry.length === 0) { |
| 135 | + // If nothing matches, tell the user and/or show them |
| 136 | + // solutions with different expiry dates |
| 137 | + if (byLabel.length === 0) { |
| 138 | + return toResult(`Could not find any Pendle asset matching '${tokenLabelNoExpiry}' with the requested expiry on ${toTitleCase(chainName)} chain`); |
| 139 | + } |
| 140 | + if (byLabel.length > 0) { |
| 141 | + return toResult( |
| 142 | + [ |
| 143 | + `Found Pendle assets matching '${tokenLabelNoExpiry}' but none of them has the requested expiry:`, |
| 144 | + byLabel.map((a) => formatPendleAsset(a, chainName, ' - ')).join('\n'), |
| 145 | + ].join('\n'), |
| 146 | + ); |
| 147 | + } |
| 148 | + } else { |
| 149 | + // Rare case in which type, underlying name and expiry are not enough to |
| 150 | + // uniquely determine a Pendle token. This might happen if two marketes |
| 151 | + // differ only in the maturation token, e.g. "PT wstETH (stETH)" and |
| 152 | + // "PT wstETH (ETH)". (Not sure these exist... but just in case.) |
| 153 | + return toResult( |
| 154 | + [ |
| 155 | + `Found multiple Pendle assets for '${tokenLabelNoExpiry}' with the requested expiry:`, |
| 156 | + byLabelAndExpiry.map((a) => formatPendleAsset(a, chainName, ' - ')).join('\n'), |
| 157 | + ].join('\n'), |
| 158 | + ); |
| 159 | + } |
| 160 | + |
| 161 | + // This should never happen |
| 162 | + return toResult('No matching Pendle asset found'); |
| 163 | +} |
| 164 | + |
| 165 | +/** |
| 166 | + * Given a Pendle asset, format it to a string |
| 167 | + */ |
| 168 | +function formatPendleAsset(asset: PendleAsset, chainName: string, prefix: string = ''): string { |
| 169 | + let tokenLabelToPrint = asset.name; |
| 170 | + if (asset.tags[0].includes('SY')) { |
| 171 | + tokenLabelToPrint = asset.symbol; |
| 172 | + } |
| 173 | + return `${prefix}${tokenLabelToPrint} on ${toTitleCase(chainName)} chain: ${asset.address}, ${asset.expiry ? `expiry: ${asset.expiry}` : 'no expiry'}`; |
| 174 | +} |
0 commit comments