Skip to content

Commit 9e284f6

Browse files
committed
fix(agent-pendle): restore buy pt routing
1 parent 02cc840 commit 9e284f6

File tree

4 files changed

+200
-22
lines changed

4 files changed

+200
-22
lines changed

typescript/clients/web-ag-ui/apps/agent-pendle/src/core/pendleFunding.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
11
import type { WalletBalance } from '../clients/onchainActions.js';
22
import type { FundingTokenOption } from '../workflow/context.js';
33

4+
type FundingTokenCandidate = FundingTokenOption & { valueUsd: number; chainId: string };
5+
6+
const parseBalanceAmount = (amount: string): bigint => {
7+
try {
8+
return BigInt(amount);
9+
} catch {
10+
return 0n;
11+
}
12+
};
13+
14+
const choosePreferredCandidate = (
15+
current: FundingTokenCandidate | undefined,
16+
next: FundingTokenCandidate,
17+
): FundingTokenCandidate => {
18+
if (!current) {
19+
return next;
20+
}
21+
if (next.valueUsd !== current.valueUsd) {
22+
return next.valueUsd > current.valueUsd ? next : current;
23+
}
24+
if (next.decimals !== current.decimals) {
25+
return next.decimals > current.decimals ? next : current;
26+
}
27+
return parseBalanceAmount(next.balance) > parseBalanceAmount(current.balance) ? next : current;
28+
};
29+
430
export function buildFundingTokenOptions(params: {
531
balances: readonly WalletBalance[];
632
whitelistSymbols: readonly string[];
733
}): FundingTokenOption[] {
834
const whitelist = new Set(params.whitelistSymbols);
9-
return params.balances
35+
const dedupedCandidates = new Map<string, FundingTokenCandidate>();
36+
37+
for (const balance of params.balances
1038
.filter((balance) => Boolean(balance.symbol) && typeof balance.decimals === 'number')
1139
.filter((balance) => whitelist.has(balance.symbol ?? ''))
1240
.map((balance) => ({
41+
chainId: balance.tokenUid.chainId,
1342
address: balance.tokenUid.address as `0x${string}`,
1443
symbol: balance.symbol ?? 'UNKNOWN',
1544
decimals: balance.decimals ?? 0,
1645
balance: balance.amount,
1746
valueUsd: balance.valueUsd ?? 0,
18-
}))
47+
}))) {
48+
const key = `${balance.chainId}:${balance.address.toLowerCase()}`;
49+
dedupedCandidates.set(key, choosePreferredCandidate(dedupedCandidates.get(key), balance));
50+
}
51+
52+
return Array.from(dedupedCandidates.values())
1953
.sort((left, right) => {
2054
if (right.valueUsd !== left.valueUsd) {
2155
return right.valueUsd - left.valueUsd;
@@ -27,8 +61,9 @@ export function buildFundingTokenOptions(params: {
2761
return left.address.localeCompare(right.address);
2862
})
2963
.map((entry) => {
30-
const { valueUsd, ...option } = entry;
64+
const { valueUsd, chainId, ...option } = entry;
3165
void valueUsd;
66+
void chainId;
3267
return option;
3368
});
3469
}

typescript/clients/web-ag-ui/apps/agent-pendle/src/core/pendleFunding.unit.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,36 @@ describe('buildFundingTokenOptions', () => {
6868
expect(options.map((option) => option.symbol)).toEqual(['USDC', 'USDC', 'USDT']);
6969
expect(options.map((option) => option.address)).toEqual(['0x0', '0x1', '0x2']);
7070
});
71+
72+
it('dedupes duplicate balances for the same token address', () => {
73+
const balances: WalletBalance[] = [
74+
balance({
75+
address: '0x0b2b2b2076d95dda7817e785989fe353fe955ef9',
76+
symbol: 'sUSDai',
77+
decimals: 18,
78+
amount: '49533087442598701335',
79+
valueUsd: 49.53,
80+
}),
81+
balance({
82+
address: '0x0b2b2b2076d95dda7817e785989fe353fe955ef9',
83+
symbol: 'sUSDai',
84+
decimals: 18,
85+
amount: '49533087442598701335',
86+
valueUsd: 49.53,
87+
}),
88+
];
89+
90+
const options = buildFundingTokenOptions({
91+
balances,
92+
whitelistSymbols: ['sUSDai'],
93+
});
94+
95+
expect(options).toHaveLength(1);
96+
expect(options[0]).toMatchObject({
97+
address: '0x0b2b2b2076d95dda7817e785989fe353fe955ef9',
98+
symbol: 'sUSDai',
99+
balance: '49533087442598701335',
100+
decimals: 18,
101+
});
102+
});
71103
});

typescript/clients/web-ag-ui/apps/agent-pendle/src/workflow/execution.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ export async function executeUnwind(params: {
656656
}
657657

658658
export async function executeInitialDeposit(params: {
659-
onchainActionsClient: Pick<OnchainActionsClient, 'createTokenizedYieldBuyPt'>;
659+
onchainActionsClient: Pick<OnchainActionsClient, 'createSwap' | 'createTokenizedYieldBuyPt'>;
660660
txExecutionMode: PendleTxExecutionMode;
661661
clients?: OnchainClients;
662662
delegationBundle?: DelegationBundle;
@@ -665,17 +665,32 @@ export async function executeInitialDeposit(params: {
665665
targetMarket: TokenizedYieldMarket;
666666
fundingAmount: string;
667667
}): Promise<ExecutionResult> {
668-
const fundingTokenUid = params.fundingToken.tokenUid;
669668
const transactions: TransactionPlan[] = [];
669+
const targetUnderlyingTokenUid = params.targetMarket.underlyingToken.tokenUid;
670+
671+
let buyInputTokenUid = params.fundingToken.tokenUid;
672+
let buyAmount = params.fundingAmount;
673+
674+
if (!isSameToken(buyInputTokenUid, targetUnderlyingTokenUid)) {
675+
const swapPlan = await params.onchainActionsClient.createSwap({
676+
walletAddress: params.walletAddress,
677+
amount: params.fundingAmount,
678+
amountType: 'exactIn',
679+
fromTokenUid: buyInputTokenUid,
680+
toTokenUid: targetUnderlyingTokenUid,
681+
slippageTolerance: '0.01',
682+
});
683+
684+
buyInputTokenUid = targetUnderlyingTokenUid;
685+
buyAmount = swapPlan.exactToAmount;
686+
transactions.push(...swapPlan.transactions);
687+
}
670688

671-
// Buy PT using the selected funding token directly.
672-
// Pendle can route from common stables (eg USDai) into PT markets even when the market underlying differs (eg sUSDai),
673-
// so we avoid forcing a generic DEX swap to the underlying first.
674689
const buyPlan = await params.onchainActionsClient.createTokenizedYieldBuyPt({
675690
walletAddress: params.walletAddress,
676691
marketAddress: params.targetMarket.marketIdentifier.address,
677-
inputTokenUid: fundingTokenUid,
678-
amount: params.fundingAmount,
692+
inputTokenUid: buyInputTokenUid,
693+
amount: buyAmount,
679694
slippage: '0.01',
680695
});
681696

typescript/clients/web-ag-ui/apps/agent-pendle/src/workflow/execution.unit.test.ts

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,102 @@ describe('executeInitialDeposit', () => {
714714
executeTransactionMock.mockReset();
715715
redeemDelegationsAndExecuteTransactionsMock.mockReset();
716716
});
717+
it('swaps to the target underlying before buying PT when the funding token differs', async () => {
718+
const createSwap = vi.fn().mockResolvedValue({
719+
exactFromAmount: '10000000',
720+
exactToAmount: '9500000000000000000',
721+
transactions: [
722+
{ type: 'EVM_TX', to: '0xswap', data: '0x03', value: '0', chainId: '42161' },
723+
],
724+
});
725+
const createTokenizedYieldBuyPt = vi.fn().mockResolvedValue({
726+
transactions: [{ type: 'EVM_TX', to: '0xbuy', data: '0x04', value: '0', chainId: '42161' }],
727+
});
728+
const onchainActionsClient = {
729+
createSwap,
730+
createTokenizedYieldBuyPt,
731+
} as unknown as Pick<OnchainActionsClient, 'createTokenizedYieldBuyPt'>;
732+
733+
executeTransactionMock
734+
.mockResolvedValueOnce({ transactionHash: '0xswaphash' })
735+
.mockResolvedValueOnce({ transactionHash: '0xbuyhash' });
736+
737+
const clients = {} as OnchainClients;
738+
739+
const fundingToken = {
740+
tokenUid: { chainId: '42161', address: '0xsusdai' },
741+
name: 'sUSDai',
742+
symbol: 'sUSDai',
743+
isNative: false,
744+
decimals: 18,
745+
iconUri: undefined,
746+
isVetted: true,
747+
};
748+
749+
const targetMarket: TokenizedYieldMarket = {
750+
marketIdentifier: { chainId: '42161', address: '0xmarket-new' },
751+
expiry: '2030-01-01',
752+
details: {},
753+
ptToken: {
754+
tokenUid: { chainId: '42161', address: '0xpt-new' },
755+
name: 'PT-NEW',
756+
symbol: 'PT-NEW',
757+
isNative: false,
758+
decimals: 18,
759+
iconUri: undefined,
760+
isVetted: true,
761+
},
762+
ytToken: {
763+
tokenUid: { chainId: '42161', address: '0xyt-new' },
764+
name: 'YT-NEW',
765+
symbol: 'YT-NEW',
766+
isNative: false,
767+
decimals: 18,
768+
iconUri: undefined,
769+
isVetted: true,
770+
},
771+
underlyingToken: {
772+
tokenUid: { chainId: '42161', address: '0xusdai' },
773+
name: 'USDai',
774+
symbol: 'USDai',
775+
isNative: false,
776+
decimals: 18,
777+
iconUri: undefined,
778+
isVetted: true,
779+
},
780+
};
781+
782+
const result = await executeInitialDeposit({
783+
onchainActionsClient,
784+
clients,
785+
txExecutionMode: 'execute',
786+
walletAddress: '0x0000000000000000000000000000000000000001',
787+
fundingToken,
788+
targetMarket,
789+
fundingAmount: '10000000',
790+
});
791+
792+
expect(createSwap).toHaveBeenCalledWith({
793+
walletAddress: '0x0000000000000000000000000000000000000001',
794+
amount: '10000000',
795+
amountType: 'exactIn',
796+
fromTokenUid: fundingToken.tokenUid,
797+
toTokenUid: targetMarket.underlyingToken.tokenUid,
798+
slippageTolerance: '0.01',
799+
});
800+
expect(createTokenizedYieldBuyPt).toHaveBeenCalledWith({
801+
walletAddress: '0x0000000000000000000000000000000000000001',
802+
marketAddress: targetMarket.marketIdentifier.address,
803+
inputTokenUid: targetMarket.underlyingToken.tokenUid,
804+
amount: '9500000000000000000',
805+
slippage: '0.01',
806+
});
807+
expect(executeTransactionMock).toHaveBeenCalledTimes(2);
808+
expect(executeTransactionMock.mock.calls[0]?.[1]).toMatchObject({ to: '0xswap' });
809+
expect(executeTransactionMock.mock.calls[1]?.[1]).toMatchObject({ to: '0xbuy' });
810+
expect(result.lastTxHash).toBe('0xbuyhash');
811+
});
812+
717813
it('buys PT using the selected funding token directly', async () => {
718814
const onchainActionsClient: Pick<OnchainActionsClient, 'createTokenizedYieldBuyPt'> = {
719815
createTokenizedYieldBuyPt: vi.fn().mockResolvedValue({
@@ -728,11 +824,11 @@ describe('executeInitialDeposit', () => {
728824
const clients = {} as OnchainClients;
729825

730826
const fundingToken = {
731-
tokenUid: { chainId: '42161', address: '0xusdc' },
732-
name: 'USDC',
733-
symbol: 'USDC',
827+
tokenUid: { chainId: '42161', address: '0xusdai' },
828+
name: 'USDai',
829+
symbol: 'USDai',
734830
isNative: false,
735-
decimals: 6,
831+
decimals: 18,
736832
iconUri: undefined,
737833
isVetted: true,
738834
};
@@ -809,11 +905,11 @@ describe('executeInitialDeposit', () => {
809905
const clients = {} as OnchainClients;
810906

811907
const fundingToken = {
812-
tokenUid: { chainId: '42161', address: '0xusdc' },
813-
name: 'USDC',
814-
symbol: 'USDC',
908+
tokenUid: { chainId: '42161', address: '0xusdai' },
909+
name: 'USDai',
910+
symbol: 'USDai',
815911
isNative: false,
816-
decimals: 6,
912+
decimals: 18,
817913
iconUri: undefined,
818914
isVetted: true,
819915
};
@@ -905,11 +1001,11 @@ describe('executeInitialDeposit', () => {
9051001
const clients = {} as OnchainClients;
9061002

9071003
const fundingToken = {
908-
tokenUid: { chainId: '42161', address: '0xusdc' },
909-
name: 'USDC',
910-
symbol: 'USDC',
1004+
tokenUid: { chainId: '42161', address: '0xusdai' },
1005+
name: 'USDai',
1006+
symbol: 'USDai',
9111007
isNative: false,
912-
decimals: 6,
1008+
decimals: 18,
9131009
iconUri: undefined,
9141010
isVetted: true,
9151011
};

0 commit comments

Comments
 (0)