Skip to content

Commit f488056

Browse files
committed
ux: allow to search pendle tokens by market
1 parent 0fccc60 commit f488056

File tree

13 files changed

+228
-167
lines changed

13 files changed

+228
-167
lines changed

projects/pendle/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,14 @@ Please note that you will be able to zap in / zap out to all the tokens supporte
5858
- [TODO] Claim all my rewards across chains on Pendle
5959
- [TODO] Claim rewards for the sUSDe Ethereum market on Pendle
6060

61-
### Find addresses of Pendle tokens
61+
### Resolve Pendle tokens
6262

63-
- Address of Pendle wstETH principal token expiring on December 2027 on Ethereum
64-
- Address of Pendle cbETH yield token on Base
65-
- Address of Pendle mUSDC SY token on Base
63+
- Address of cbETH yield token on Pendle on Base
64+
- Address of mUSDC SY token on Pendle on Base
65+
- Address of wstETH principal token expiring on December 2027 on Pendle on Ethereum
66+
- Address of wstETH principal tokens that expired in 2023 on Pendle on Ethereum
67+
68+
The Pendle token resolver is a crucial component of the integration as it determines what part of the protocol the user wants to interact with. The resolver first checks the token name and symbol, then if it does not find a match, it scans available markets that match the user query.
6669

6770
## Test with the local agent
6871

projects/pendle/TODO.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44

55
## Minor
66

7-
- Update `executableFunctions` in `index.ts`
8-
- SY and LP support in swap tool?
9-
- Generic token support in swap tool?
7+
- Refactor convert call and/or token approval and/or send TX
8+
- Use toHumanReadable instead of formatUnits in add liq and remove liq tools
109

1110
## Future

projects/pendle/src/agent/agent.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,11 @@ interface ConversationMessage {
3131
name?: string;
3232
}
3333

34-
function getSystemPrompt(account: string) {
34+
function getSystemPrompt() {
3535
return `You will interact with ${PROTOCOL_NAME} protocol via your tools.
3636
You MUST ALWAYS call a tool to get information.
3737
NEVER try to guess token addresses or pool addresses without calling the appropriate tool.
38-
You WILL NOT modify token and pool addresses, names, ids or symbols, not even to make them plural.
39-
All tools that require the 'chainName' and 'account' arguments will use the following default values:
40-
chainName = Ethereum
41-
account = ${account}".`;
38+
You WILL NOT modify token and pool addresses, names, ids or symbols, not even to make them plural.`;
4239
}
4340

4441
/**
@@ -170,7 +167,7 @@ export async function agent({ action, debugLlm, debugTools, notify }: Options):
170167
const messages: ConversationMessage[] = [
171168
{
172169
role: 'system',
173-
content: getSystemPrompt(signer.address),
170+
content: getSystemPrompt(),
174171
},
175172
{ role: 'user', content: action },
176173
];

projects/pendle/src/agent/functions/getTokenBalance.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,37 @@
11
import { Address, erc20Abi, formatUnits } from 'viem';
22
import { EVM, EvmChain, FunctionReturn, toResult, FunctionOptions } from '@heyanon/sdk';
33
import { supportedChains } from '../../constants';
4-
import { getTokenInfoFromAddress } from '../tokens';
4+
import { fetchTokenInfoFromAddress } from '../../helpers/tokens';
55

66
interface Props {
77
chainName: string;
8-
account: Address;
98
tokenAddress: Address;
9+
userAddress: Address | null;
1010
}
1111

1212
/**
13-
* Gets the token balance for a specific account.
13+
* Gets the token balance for a specific address.
1414
* Returns balance in human readable format.
1515
*
1616
* @param {Object} props - The input parameters
1717
* @param {string} props.chainName - Name of the blockchain network
18-
* @param {Address} props.account - Address to check balance for
1918
* @param {Address} props.tokenAddress - Address of token to check
19+
* @param {Address} props.userAddress - Address to check balance for
2020
* @param {FunctionOptions} options - HeyAnon SDK options, including provider and notification handlers
2121
* @returns {Promise<FunctionReturn>} Token balance with symbol
2222
*/
23-
export async function getTokenBalance({ chainName, account, tokenAddress }: Props, { notify, evm: { getProvider } }: FunctionOptions): Promise<FunctionReturn> {
23+
export async function getTokenBalance({ chainName, tokenAddress, userAddress }: Props, { notify, evm: { getAddress, getProvider } }: FunctionOptions): Promise<FunctionReturn> {
2424
const chainId = EVM.utils.getChainFromName(chainName as EvmChain);
2525
if (!chainId) return toResult(`Unsupported chain name: ${chainName}`, true);
2626
if (!supportedChains.includes(chainId)) return toResult(`Unsupported chain: ${chainName}`, true);
2727

28-
const token = getTokenInfoFromAddress(chainName as EvmChain, tokenAddress);
28+
const token = await fetchTokenInfoFromAddress(getProvider(chainId), tokenAddress);
2929
if (!token) return toResult(`Token not found: ${tokenAddress}`, true);
30+
const account = userAddress ?? (await getAddress());
3031

3132
const publicClient = getProvider(chainId);
3233

33-
await notify(`Getting ${token.symbol} balance...`);
34+
await notify(`Getting ${token.symbol} balance for ${userAddress ?? 'your wallet'}...`);
3435

3536
const balance = await publicClient.readContract({
3637
address: tokenAddress,

projects/pendle/src/agent/tokens.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface TokenInfo {
1010
}
1111

1212
/**
13-
* Tokens supported by the agent
13+
* List of tokens that the agent will be able to resolve from their symbol
1414
*/
1515
export const tokens: Partial<Record<EvmChain, TokenInfo[]>> = {
1616
[Chain.ETHEREUM]: [
@@ -91,12 +91,3 @@ export function getTokenInfoFromSymbol(chainName: EvmChain, symbol: string): Tok
9191
if (!chainTokens) return null;
9292
return chainTokens.find((token) => token.symbol.toUpperCase() === symbol.toUpperCase()) || null;
9393
}
94-
95-
/**
96-
* Return token details from its address
97-
*/
98-
export function getTokenInfoFromAddress(chainName: EvmChain, address: `0x${string}`): TokenInfo | null {
99-
const chainTokens = tokens[chainName];
100-
if (!chainTokens) return null;
101-
return chainTokens.find((token) => token.address.toLowerCase() === address.toLowerCase()) || null;
102-
}

projects/pendle/src/agent/tools.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const tools = [
3737
type: 'function',
3838
function: {
3939
name: 'getTokenBalance',
40-
description: "Get the balance of a token in the user's wallet.",
40+
description: 'Fetch on-chain the balance for the given token for the given user address.',
4141
parameters: {
4242
type: 'object',
4343
properties: {
@@ -46,16 +46,16 @@ export const tools = [
4646
enum: supportedChains.map(EVM.utils.getChainName),
4747
description: 'Chain name',
4848
},
49-
account: {
50-
type: 'string',
51-
description: 'Account address',
52-
},
5349
tokenAddress: {
5450
type: 'string',
55-
description: 'Token address',
51+
description: 'Token address (e.g. "0x...")',
52+
},
53+
userAddress: {
54+
type: ['string', 'null'],
55+
description: "User address to check the balance for. If not provided, the function will use the agent's wallet address.",
5656
},
5757
},
58-
required: ['chainName', 'account', 'tokenAddress'],
58+
required: ['chainName', 'tokenAddress', 'userAddress'],
5959
additionalProperties: false,
6060
},
6161
},

projects/pendle/src/functions/getDataOnMarket.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@ export async function getDataOnMarket({ chainName, marketAddress }: Props, { not
3434
}
3535

3636
// Format and return result
37-
return toResult(formatMarketData(marketData, market));
37+
// NOTA BENE: It is important to include the tokens addresses in the result,
38+
// in case the getPendleTokenAddressFromTypeAndName is not enough to resolve
39+
// a Pendle token requested by the user.
40+
return toResult(formatMarketData(marketData, market, true));
3841
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)