Skip to content
Merged
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
4 changes: 2 additions & 2 deletions skills/pendle-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "pendle",
"description": "Pendle Finance yield tokenization plugin buy/sell PT & YT, add/remove liquidity, mint/redeem PT+YT pairs across Ethereum, Arbitrum, BSC, and Base",
"version": "0.2.1"
"description": "Pendle Finance yield tokenization plugin \u2014 buy/sell PT & YT, add/remove liquidity, mint/redeem PT+YT pairs across Ethereum, Arbitrum, BSC, and Base",
"version": "0.2.3"
}
4 changes: 2 additions & 2 deletions skills/pendle-plugin/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion skills/pendle-plugin/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pendle-plugin"
version = "0.2.2"
version = "0.2.3"
edition = "2021"

[[bin]]
Expand Down
315 changes: 230 additions & 85 deletions skills/pendle-plugin/SKILL.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion skills/pendle-plugin/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema_version: 1
name: pendle-plugin
version: "0.2.2"
version: "0.2.3"
description: "Pendle Finance yield tokenization plugin — buy/sell PT & YT, add/remove liquidity, mint/redeem PT+YT pairs across Ethereum, Arbitrum, BSC, and Base"
author:
name: skylavis-sky
Expand Down
116 changes: 115 additions & 1 deletion skills/pendle-plugin/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,76 @@ pub struct SdkTokenAmount {
pub amount: String,
}

/// Extract calldata and router address from SDK convert response
/// Validate calldata and router address returned by the Pendle Hosted SDK.
///
/// Guards against a supply-chain attack where a compromised SDK response returns
/// calldata that drains the wallet via a standard ERC-20/ERC-721 operation, or
/// routes funds through an unknown contract.
///
/// Checks (in order):
/// 1. Calldata is well-formed hex with at least a 4-byte selector.
/// 2. router_to is Pendle Router v3 or a known DEX aggregator.
/// 3. Selector is not a standard token drain operation (transfer, transferFrom,
/// approve, setApprovalForAll, safeTransferFrom).
pub fn validate_sdk_calldata(calldata: &str, router_to: &str) -> anyhow::Result<()> {
// 1. Well-formed hex, at least 4 bytes (8 hex chars after 0x prefix)
let hex = calldata.strip_prefix("0x").unwrap_or(calldata);
if hex.len() < 8 {
anyhow::bail!(
"SDK returned malformed calldata (too short — expected at least 4 bytes): '{}'",
calldata
);
}
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!(
"SDK returned non-hex calldata: '{}'",
&calldata[..calldata.len().min(20)]
);
}

// 2. router_to must be in the Pendle / known aggregator whitelist
let router_lower = router_to.to_lowercase();
let known_routers: &[&str] = &[
"0x888888888889758f76e7103c6cbf23abbf58f946", // Pendle Router v3
"0x1111111254eeb25477b68fb85ed929f73a960582", // 1inch v5
"0x111111125421ca6dc452d289314280a0f8842a65", // 1inch v6
"0xdef1c0ded9bec7f1a1670819833240f027b25eff", // 0x Exchange Proxy
"0xe592427a0aece92de3edee1f18e0157c05861564", // Uniswap v3 SwapRouter
];
if !known_routers.contains(&router_lower.as_str()) {
anyhow::bail!(
"SDK returned unrecognised router address '{}'. Expected Pendle Router v3 \
(0x8888...8946) or a known DEX aggregator. Aborting to prevent funds being \
routed to an unexpected contract.",
router_to
);
}

// 3. Selector must not be a standard ERC-20/ERC-721 token operation
let selector = hex[..8].to_lowercase();
let dangerous: &[(&str, &str)] = &[
("a9059cbb", "transfer(address,uint256)"),
("23b872dd", "transferFrom(address,address,uint256)"),
("095ea7b3", "approve(address,uint256)"),
("a22cb465", "setApprovalForAll(address,bool)"),
("42842e0e", "safeTransferFrom(address,address,uint256)"),
("b88d4fde", "safeTransferFrom(address,address,uint256,bytes)"),
];
for (sel, sig) in dangerous {
if selector == *sel {
anyhow::bail!(
"SDK returned calldata with selector 0x{} ({}). This is a token operation, \
not a Pendle Router call. Aborting to prevent unintended token transfer or approval.",
sel, sig
);
}
}

Ok(())
}

/// Extract calldata and router address from SDK convert response.
/// Validates the calldata with `validate_sdk_calldata` before returning.
pub fn extract_sdk_calldata(response: &Value) -> anyhow::Result<(String, String)> {
let routes = response["routes"]
.as_array()
Expand All @@ -266,9 +335,54 @@ pub fn extract_sdk_calldata(response: &Value) -> anyhow::Result<(String, String)
.as_str()
.unwrap_or(crate::config::PENDLE_ROUTER)
.to_string();
validate_sdk_calldata(&calldata, &to)?;
Ok((calldata, to))
}

/// Extract the expected output amount from SDK convert response.
///
/// Pendle SDK v3 response layout:
/// routes[0].outputs[0].amount ← primary (confirmed via live API)
/// routes[0].data.* ← fallback for older SDK shapes
pub fn extract_amount_out(response: &Value) -> Option<String> {
let route = response["routes"].as_array()?.first()?;

// Primary: Pendle SDK v3 places output amount at routes[0].outputs[0].amount
if let Some(outputs) = route["outputs"].as_array() {
if let Some(first) = outputs.first() {
if let Some(s) = first["amount"].as_str() {
return Some(s.to_string());
}
if let Some(n) = first["amount"].as_u64() {
return Some(n.to_string());
}
}
}

// Fallback: older SDK field names under routes[0].data
let data = &route["data"];
for field in &["netPtOut", "netYtOut", "netLpOut", "netTokenOut", "amountOut", "outputAmount"] {
if let Some(s) = data[field].as_str() {
return Some(s.to_string());
}
if let Some(n) = data[field].as_u64() {
return Some(n.to_string());
}
}
None
}

/// Extract price impact from SDK convert response.
/// The SDK reports priceImpact as a negative decimal (e.g. -0.015 = 1.5% loss).
/// Returns Some(pct) as a positive percentage value, or None if the field is absent.
pub fn extract_price_impact(response: &Value) -> Option<f64> {
let route = response["routes"].as_array()?.first()?;
let impact = route["data"]["priceImpact"]
.as_f64()
.or_else(|| route["data"]["price_impact"].as_f64())?;
Some(impact.abs() * 100.0)
}

/// Extract required approvals from SDK convert response
pub fn extract_required_approvals(response: &Value) -> Vec<(String, String)> {
// Returns list of (token_address, spender_address) pairs
Expand Down
40 changes: 39 additions & 1 deletion skills/pendle-plugin/src/commands/add_liquidity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub async fn run(
from: Option<&str>,
slippage: f64,
dry_run: bool,
confirm: bool,
api_key: Option<&str>,
) -> Result<Value> {
// Validate inputs
Expand All @@ -27,6 +28,19 @@ pub async fn run(
anyhow::bail!("Cannot resolve wallet address. Pass --from or ensure onchainos is logged in.");
}

// Pre-flight balance check: verify wallet holds enough token_in before calling the SDK
if !dry_run {
let balance = onchainos::erc20_balance_of(chain_id, token_in, &wallet).await.unwrap_or(0);
let required: u128 = amount_in.parse().unwrap_or(0);
if balance < required {
anyhow::bail!(
"Insufficient balance: wallet {} holds {} wei of token {} but {} wei is required. \
Acquire more before retrying.",
wallet, balance, token_in, required
);
}
}

// Hosted SDK routes automatically to addLiquiditySingleToken
let sdk_resp = api::sdk_convert(
chain_id,
Expand All @@ -46,6 +60,27 @@ pub async fn run(

let (calldata, router_to) = api::extract_sdk_calldata(&sdk_resp)?;
let approvals = api::extract_required_approvals(&sdk_resp);
let expected_lp_out = api::extract_amount_out(&sdk_resp);

// Preview gate: show SDK quote without executing
if !confirm && !dry_run {
return Ok(serde_json::json!({
"ok": true,
"preview": true,
"note": "Preview — add --confirm to execute on-chain.",
"operation": "add-liquidity",
"chain_id": chain_id,
"token_in": token_in,
"amount_in": amount_in,
"lp_address": lp_address,
"expected_lp_out": expected_lp_out,
"router": router_to,
"calldata": calldata,
"wallet": wallet,
"required_approvals": approvals.len(),
}));
}

let amount_in_wei: u128 = amount_in.parse().map_err(|_| anyhow::anyhow!("Failed to parse amount-in: '{}'", amount_in))?;

let mut approve_hashes: Vec<String> = Vec::new();
Expand All @@ -59,7 +94,9 @@ pub async fn run(
dry_run,
)
.await?;
approve_hashes.push(onchainos::extract_tx_hash(&approve_result)?);
let approve_hash = onchainos::extract_tx_hash(&approve_result)?;
if !dry_run { onchainos::wait_for_tx(&approve_hash, onchainos::default_rpc_url(chain_id)).await; }
approve_hashes.push(approve_hash);
}

let result = onchainos::wallet_contract_call(
Expand All @@ -82,6 +119,7 @@ pub async fn run(
"amount_in": amount_in,
"lp_address": lp_address,
"min_lp_out": min_lp_out,
"expected_lp_out": expected_lp_out,
"router": router_to,
"calldata": calldata,
"wallet": wallet,
Expand Down
40 changes: 39 additions & 1 deletion skills/pendle-plugin/src/commands/buy_pt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub async fn run(
from: Option<&str>,
slippage: f64,
dry_run: bool,
confirm: bool,
api_key: Option<&str>,
) -> Result<Value> {
// Validate inputs
Expand All @@ -28,6 +29,19 @@ pub async fn run(
anyhow::bail!("Cannot resolve wallet address. Pass --from or ensure onchainos is logged in.");
}

// Pre-flight balance check: verify wallet holds enough token_in before calling the SDK
if !dry_run {
let balance = onchainos::erc20_balance_of(chain_id, token_in, &wallet).await.unwrap_or(0);
let required: u128 = amount_in.parse().unwrap_or(0);
if balance < required {
anyhow::bail!(
"Insufficient balance: wallet {} holds {} wei of token {} but {} wei is required. \
Acquire more before retrying.",
wallet, balance, token_in, required
);
}
}

// Call Pendle Hosted SDK to generate calldata
let sdk_resp = api::sdk_convert(
chain_id,
Expand All @@ -47,6 +61,27 @@ pub async fn run(

let (calldata, router_to) = api::extract_sdk_calldata(&sdk_resp)?;
let approvals = api::extract_required_approvals(&sdk_resp);
let expected_pt_out = api::extract_amount_out(&sdk_resp);

// Preview gate: show SDK quote without executing
if !confirm && !dry_run {
return Ok(serde_json::json!({
"ok": true,
"preview": true,
"note": "Preview — add --confirm to execute on-chain.",
"operation": "buy-pt",
"chain_id": chain_id,
"token_in": token_in,
"amount_in": amount_in,
"pt_address": pt_address,
"expected_pt_out": expected_pt_out,
"router": router_to,
"calldata": calldata,
"wallet": wallet,
"required_approvals": approvals.len(),
}));
}

let amount_in_wei: u128 = amount_in.parse().map_err(|_| anyhow::anyhow!("Failed to parse amount-in: '{}'", amount_in))?;

let mut approve_hashes: Vec<String> = Vec::new();
Expand All @@ -62,7 +97,9 @@ pub async fn run(
dry_run,
)
.await?;
approve_hashes.push(onchainos::extract_tx_hash(&approve_result)?);
let approve_hash = onchainos::extract_tx_hash(&approve_result)?;
if !dry_run { onchainos::wait_for_tx(&approve_hash, onchainos::default_rpc_url(chain_id)).await; }
approve_hashes.push(approve_hash);
}

// Submit main buy-PT transaction
Expand All @@ -86,6 +123,7 @@ pub async fn run(
"amount_in": amount_in,
"pt_address": pt_address,
"min_pt_out": min_pt_out,
"expected_pt_out": expected_pt_out,
"router": router_to,
"calldata": calldata,
"wallet": wallet,
Expand Down
40 changes: 39 additions & 1 deletion skills/pendle-plugin/src/commands/buy_yt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub async fn run(
from: Option<&str>,
slippage: f64,
dry_run: bool,
confirm: bool,
api_key: Option<&str>,
) -> Result<Value> {
// Validate inputs
Expand All @@ -27,6 +28,19 @@ pub async fn run(
anyhow::bail!("Cannot resolve wallet address. Pass --from or ensure onchainos is logged in.");
}

// Pre-flight balance check: verify wallet holds enough token_in before calling the SDK
if !dry_run {
let balance = onchainos::erc20_balance_of(chain_id, token_in, &wallet).await.unwrap_or(0);
let required: u128 = amount_in.parse().unwrap_or(0);
if balance < required {
anyhow::bail!(
"Insufficient balance: wallet {} holds {} wei of token {} but {} wei is required. \
Acquire more before retrying.",
wallet, balance, token_in, required
);
}
}

let sdk_resp = api::sdk_convert(
chain_id,
&wallet,
Expand All @@ -45,6 +59,27 @@ pub async fn run(

let (calldata, router_to) = api::extract_sdk_calldata(&sdk_resp)?;
let approvals = api::extract_required_approvals(&sdk_resp);
let expected_yt_out = api::extract_amount_out(&sdk_resp);

// Preview gate: show SDK quote without executing
if !confirm && !dry_run {
return Ok(serde_json::json!({
"ok": true,
"preview": true,
"note": "Preview — add --confirm to execute on-chain.",
"operation": "buy-yt",
"chain_id": chain_id,
"token_in": token_in,
"amount_in": amount_in,
"yt_address": yt_address,
"expected_yt_out": expected_yt_out,
"router": router_to,
"calldata": calldata,
"wallet": wallet,
"required_approvals": approvals.len(),
}));
}

let amount_in_wei: u128 = amount_in.parse().map_err(|_| anyhow::anyhow!("Failed to parse amount-in: '{}'", amount_in))?;

let mut approve_hashes: Vec<String> = Vec::new();
Expand All @@ -58,7 +93,9 @@ pub async fn run(
dry_run,
)
.await?;
approve_hashes.push(onchainos::extract_tx_hash(&approve_result)?);
let approve_hash = onchainos::extract_tx_hash(&approve_result)?;
if !dry_run { onchainos::wait_for_tx(&approve_hash, onchainos::default_rpc_url(chain_id)).await; }
approve_hashes.push(approve_hash);
}

let result = onchainos::wallet_contract_call(
Expand All @@ -81,6 +118,7 @@ pub async fn run(
"amount_in": amount_in,
"yt_address": yt_address,
"min_yt_out": min_yt_out,
"expected_yt_out": expected_yt_out,
"router": router_to,
"calldata": calldata,
"wallet": wallet,
Expand Down
Loading
Loading