Skip to content

feat: add stke sol by sol strategies#2341

Merged
0xkr3p merged 5 commits intoDefiLlama:masterfrom
0xkr3p:feat/add-stke-sol
Feb 18, 2026
Merged

feat: add stke sol by sol strategies#2341
0xkr3p merged 5 commits intoDefiLlama:masterfrom
0xkr3p:feat/add-stke-sol

Conversation

@0xkr3p
Copy link
Copy Markdown
Contributor

@0xkr3p 0xkr3p commented Feb 4, 2026

Summary by CodeRabbit

  • New Features

    • Support for STKESOL staking pool: TVL in USD, APY reporting, fee metadata, and resilient historical-price APY fallback.
    • New Solana stake-pool utilities to fetch and interpret on-chain metrics for accurate TVL and APY.
  • Chores

    • Added Solana stake-pool dependency to enable the new utilities.

@0xkr3p 0xkr3p changed the title add stke sol by sol strategies feat: add stke sol by sol strategies Feb 4, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

Adds a Solana STKESOL adapter computing APY from on‑chain stake pool snapshots and SOL price, new Solana RPC utilities for decoding stake pool account data and APY helpers, and a dependency on @solana/spl-stake-pool. (≤50 words)

Changes

Cohort / File(s) Summary
New STKESOL Adapter
src/adaptors/stkesol-by-sol-strategies/index.js
New adapter exporting timetravel: false, apy() and url. apy() concurrently fetches stake pool info and SOL price, validates SOL price (throws if missing), computes apyBase via calcSolanaLstApy(stakePool), computes tvlUsd from tvlSol * solPrice, and returns a single pool metadata entry including underlyingTokens and poolMeta (fee from on‑chain epochFee).
Solana Utilities
src/adaptors/utils.js
Adds getSolanaAccountInfo() and getStakePoolInfo(stakePoolAddress, rpcUrl) using @solana/spl-stake-pool layout to decode account data and derive totalLamports, poolTokenSupply, exchangeRate, tvlSol, last‑epoch snapshots, epochFee, and epochsPerYear. Adds calcSolanaLstApy(stakePoolInfo) and calcSolanaLstApyFromPriceRatio(...) with guards for missing data and divide‑by‑zero.
Dependency
package.json
Adds dependency @solana/spl-stake-pool (^1.1.8) for stake pool account decoding.

Sequence Diagram

sequenceDiagram
    participant Client as Caller
    participant Adapter as STKESOL Adapter
    participant Solana as Solana RPC
    participant Price as Price Oracle

    Client->>Adapter: apy()
    par Parallel fetch
        Adapter->>Solana: getStakePoolInfo(stakePoolAddress)
        Solana-->>Adapter: stake pool account data
        Adapter->>Adapter: decode stake pool (totalLamports, poolTokenSupply, snapshots)
        Adapter->>Price: getCurrentPrice(SOL)
        Price-->>Adapter: currentSolPrice
    end
    Adapter->>Adapter: calcSolanaLstApy(stakePoolInfo)
    alt SOL price missing
        Adapter-->>Client: throw "Unable to fetch SOL price"
    else
        Adapter->>Adapter: build pool metadata (tvlUsd, apyBase, poolMeta)
        Adapter-->>Client: [ pool metadata ]
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through lamports, decoded the night,
RPCs hummed softly, prices took flight,
A ratio twitched, APY glowed bright,
STKESOL now sings in morning light. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title contains a typo ('stke' instead of 'stke') and is therefore unclear. However, the PR objectively adds the stkesol-by-sol-strategies adapter, which is identifiable despite the typo. The title refers to the main addition but could be more professional.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@llamatester
Copy link
Copy Markdown

The stkesol-by-sol-strategies adapter exports pools:

Test Suites: 1 passed, 1 total
Tests: 10 passed, 10 total
Snapshots: 0 total
Time: 0.239 s
Ran all test suites.

Nb of pools: 1
 

Sample pools:
┌─────────┬───────────────────────────────────────────────┬──────────┬─────────────────────────────┬───────────┬───────────────────┬───────────────────┬───────────────────────────────────────────────────┬─────────────────────────────────────┐
│ (index) │ pool                                          │ chain    │ project                     │ symbol    │ tvlUsd            │ apyBase           │ underlyingTokens                                  │ poolMeta                            │
├─────────┼───────────────────────────────────────────────┼──────────┼─────────────────────────────┼───────────┼───────────────────┼───────────────────┼───────────────────────────────────────────────────┼─────────────────────────────────────┤
│ 0       │ 'stke7uu3fXHsGqKVVjKnkmj65LRPVrqr4bLG2SJg7rh' │ 'Solana' │ 'stkesol-by-sol-strategies' │ 'STKESOL' │ 60142061.17512516 │ 4.365974420611196 │ [ 'So11111111111111111111111111111111111111112' ] │ '5% epoch fee, 0.1% withdrawal fee' │
└─────────┴───────────────────────────────────────────────┴──────────┴─────────────────────────────┴───────────┴───────────────────┴───────────────────┴───────────────────────────────────────────────────┴─────────────────────────────────────┘

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/adaptors/stkesol-by-sol-strategies/index.js`:
- Around line 44-47: The calculation can divide by zero when stkePrice or
solPrice is 0 and can produce extreme/Infinity APY when days is near 0; in the
function that computes APY (use symbols historicalRatio, stkePrice, solPrice,
ratioChange, days) add guards: if stkePrice or solPrice are falsy/zero return a
safe sentinel (null or 0) or throw, ensure historicalRatio and ratioChange are
finite and >0 before taking powers, enforce a minimum days threshold (e.g.
minDays = 1) or clamp 365/days to a reasonable max exponent, and after computing
APY verify Number.isFinite(result) and clamp to a sane max/min before returning.

In `@src/adaptors/utils.js`:
- Around line 569-573: The current exchangeRate calculation can divide by zero
when poolTokenSupply is zero; update the code around totalLamports,
poolTokenSupply and exchangeRate (values read via buffer.readBigUInt64LE) to
check poolTokenSupply first (use a BigInt comparison like poolTokenSupply ===
0n) and handle that case explicitly (e.g., set exchangeRate to 0 or null, or
early-return/log an error) instead of performing Number(totalLamports) /
Number(poolTokenSupply); only perform the division when poolTokenSupply is
non-zero.
- Around line 566-570: The code reads totalLamports and poolTokenSupply from
hardcoded offsets in src/adaptors/utils.js (buffer.readBigUInt64LE(258) and
buffer.readBigUInt64LE(266)), which is fragile; replace this by using the
official `@solana/spl-stake-pool` decoder to parse the stake pool account data
instead of manual offsets: add/import the appropriate decoder from
`@solana/spl-stake-pool`, call its decode/deserialize method on buffer (or the
StakePool layout/type) to obtain the parsed object, then read
parsed.totalLamports and parsed.poolTokenSupply (or the library field names)
instead of buffer.readBigUInt64LE; ensure the package is added to dependencies
and update any variable names (buffer -> stakePoolAccountData) accordingly.
🧹 Nitpick comments (3)
src/adaptors/utils.js (1)

538-559: Add timeout to Solana RPC request.

The axios request has no timeout configured, which could cause the adapter to hang indefinitely if the RPC endpoint is unresponsive.

⏱️ Proposed fix to add timeout
 const getSolanaAccountInfo = async (address, rpcUrl = 'https://api.mainnet-beta.solana.com') => {
   const response = await axios.post(rpcUrl, {
     jsonrpc: '2.0',
     id: 1,
     method: 'getAccountInfo',
     params: [address, { encoding: 'base64' }],
   }, {
     headers: { 'Content-Type': 'application/json' },
+    timeout: 30000,
   });
src/adaptors/stkesol-by-sol-strategies/index.js (2)

17-19: Add timeout to axios requests.

Multiple axios calls lack timeout configuration. If the DefiLlama API is slow or unresponsive, the adapter could hang indefinitely.

⏱️ Proposed fix to add timeout
   const historicalRes = await axios.get(
-    `https://coins.llama.fi/prices/historical/${thirtyDaysAgo}/${stkesolKey},${solKey}`
+    `https://coins.llama.fi/prices/historical/${thirtyDaysAgo}/${stkesolKey},${solKey}`,
+    { timeout: 30000 }
   );

Apply similar timeout configuration to other axios.get calls at lines 27, 33, and 53.

Also applies to: 27-28, 33-34, 53-53


50-54: Consider adding error handling for stake pool fetch failure.

If getStakePoolInfo throws (e.g., network error, account not found), the entire apy() function will fail. Depending on yield-server's behavior for failed adapters, you may want to catch and log the error, returning an empty array instead.

🛡️ Optional: wrap in try-catch for graceful degradation
 const apy = async () => {
+  try {
     const [stakePool, priceRes] = await Promise.all([
       getStakePoolInfo(STAKE_POOL),
       axios.get(`https://coins.llama.fi/prices/current/${solKey}`),
     ]);

     const solPrice = priceRes.data.coins[solKey]?.price;
     if (!solPrice) throw new Error('Unable to fetch SOL price');

     return [
       {
         pool: STKESOL_MINT,
         chain: 'Solana',
         project: 'stkesol-by-sol-strategies',
         symbol: 'STKESOL',
         tvlUsd: stakePool.tvlSol * solPrice,
         apyBase: await calculateApy(stakePool.exchangeRate),
         underlyingTokens: [SOL],
         poolMeta: '5% epoch fee, 0.1% withdrawal fee',
       },
     ];
+  } catch (err) {
+    console.error(`stkesol-by-sol-strategies error: ${err.message}`);
+    return [];
+  }
 };

Comment thread src/adaptors/stkesol-by-sol-strategies/index.js Outdated
Comment thread src/adaptors/utils.js Outdated
Comment thread src/adaptors/utils.js Outdated
@llamatester
Copy link
Copy Markdown

The stkesol-by-sol-strategies adapter exports pools:

Test Suites: 1 passed, 1 total
Tests: 10 passed, 10 total
Snapshots: 0 total
Time: 0.248 s
Ran all test suites.

Nb of pools: 1
 

Sample pools:
┌─────────┬───────────────────────────────────────────────┬──────────┬─────────────────────────────┬───────────┬────────────────────┬───────────────────┬───────────────────────────────────────────────────┬─────────────────────────────────────┐
│ (index) │ pool                                          │ chain    │ project                     │ symbol    │ tvlUsd             │ apyBase           │ underlyingTokens                                  │ poolMeta                            │
├─────────┼───────────────────────────────────────────────┼──────────┼─────────────────────────────┼───────────┼────────────────────┼───────────────────┼───────────────────────────────────────────────────┼─────────────────────────────────────┤
│ 0       │ 'stke7uu3fXHsGqKVVjKnkmj65LRPVrqr4bLG2SJg7rh' │ 'Solana' │ 'stkesol-by-sol-strategies' │ 'STKESOL' │ 61317044.827933304 │ 4.360980777589019 │ [ 'So11111111111111111111111111111111111111112' ] │ '5% epoch fee, 0.1% withdrawal fee' │
└─────────┴───────────────────────────────────────────────┴──────────┴─────────────────────────────┴───────────┴────────────────────┴───────────────────┴───────────────────────────────────────────────────┴─────────────────────────────────────┘

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/adaptors/stkesol-by-sol-strategies/index.js`:
- Around line 49-53: The APY calculation uses currentExchangeRate and
historicalRatio directly, which can produce ratioChange=0 and yield a misleading
-100% APY; update the logic in the function around the variables
historicalRatio, ratioChange and apy to first validate currentExchangeRate > 0
and historicalRatio > 0 (and that days > 0), and if any are invalid return null
(or skip publishing) instead of computing Math.pow; ensure the early-return
happens before computing ratioChange or apy so getStakePoolInfo
returns/propagates a null result for empty/invalid pools.

In `@src/adaptors/utils.js`:
- Around line 567-575: StakePoolLayout.decode returns BN instances for
totalLamports and poolTokenSupply, so the current zero-check poolTokenSupply ===
0n will always fail; convert those BN values to bigint (or Number safely when
within JS number range) before checking for zero and computing exchangeRate:
read stakePool.totalLamports and stakePool.poolTokenSupply, convert to bigint
(e.g., BigInt(stakePool.poolTokenSupply.toString())) then check == 0n and
compute exchangeRate using converted values (use Number(...) only after ensuring
safe range) so division-by-zero is correctly guarded in the exchangeRate
calculation.

Comment on lines +49 to +53
const historicalRatio = stkePrice / solPrice;
const ratioChange = currentExchangeRate / historicalRatio;

const apy = (Math.pow(ratioChange, 365 / days) - 1) * 100;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against zero/invalid currentExchangeRate to avoid -100% APY.

If getStakePoolInfo returns exchangeRate = 0 (e.g., empty pool or decode issue), Line 50 yields ratioChange = 0, producing an APY of -100%. Returning null is safer than publishing a misleading negative APY.

🛡️ Suggested guard
   if (!stkePrice || !solPrice) {
     return null;
   }

+  if (!currentExchangeRate || currentExchangeRate <= 0) return null;
+
   const historicalRatio = stkePrice / solPrice;
   const ratioChange = currentExchangeRate / historicalRatio;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const historicalRatio = stkePrice / solPrice;
const ratioChange = currentExchangeRate / historicalRatio;
const apy = (Math.pow(ratioChange, 365 / days) - 1) * 100;
if (!stkePrice || !solPrice) {
return null;
}
if (!currentExchangeRate || currentExchangeRate <= 0) return null;
const historicalRatio = stkePrice / solPrice;
const ratioChange = currentExchangeRate / historicalRatio;
const apy = (Math.pow(ratioChange, 365 / days) - 1) * 100;
🤖 Prompt for AI Agents
In `@src/adaptors/stkesol-by-sol-strategies/index.js` around lines 49 - 53, The
APY calculation uses currentExchangeRate and historicalRatio directly, which can
produce ratioChange=0 and yield a misleading -100% APY; update the logic in the
function around the variables historicalRatio, ratioChange and apy to first
validate currentExchangeRate > 0 and historicalRatio > 0 (and that days > 0),
and if any are invalid return null (or skip publishing) instead of computing
Math.pow; ensure the early-return happens before computing ratioChange or apy so
getStakePoolInfo returns/propagates a null result for empty/invalid pools.

Comment thread src/adaptors/utils.js
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/adaptors/utils.js (1)

569-569: require call placed mid-file.

The require('@solana/spl-stake-pool') at Line 569 is sandwiched between function definitions rather than at the top of the module with other imports. Move it to the top of the file alongside the existing require calls.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adaptors/utils.js` at line 569, The require call for
'@solana/spl-stake-pool' (used as StakePoolLayout) is currently placed mid-file;
move the const { StakePoolLayout } = require('@solana/spl-stake-pool') statement
up into the top of the module with the other require/import statements so all
dependencies are declared together and available before functions that reference
StakePoolLayout; update any linter ordering if needed and ensure there are no
duplicate imports for StakePoolLayout after moving it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/adaptors/stkesol-by-sol-strategies/index.js`:
- Around line 22-24: getStakePoolInfo currently doesn't populate a withdrawal
fee field, so poolMeta only shows epochFee; update getStakePoolInfo to extract
the withdrawal fee (use stakePool.stakeWithdrawalFee or
stakePool.solWithdrawalFee as present) calculate it as a percentage string
similar to epochFee (e.g., ((numerator/denominator)*100).toFixed(1) + '%
withdrawal fee'), add that value to the returned object (e.g., withdrawalFee or
solWithdrawalFee), and then include that field in poolMeta so the output becomes
"X% epoch fee, Y% withdrawal fee" alongside existing epoch fee formatting.
- Line 34: The field underlyingTokens currently uses STKESOL_MINT (the LST) but
must reference the deposited asset SOL; update the config in the object that
defines underlyingTokens (replace STKESOL_MINT with SOL) so underlyingTokens:
[SOL] is used; ensure the SOL constant (already defined) is referenced and
remove the incorrect STKESOL_MINT usage in that underlyingTokens assignment.

---

Duplicate comments:
In `@src/adaptors/utils.js`:
- Around line 577-584: The division-by-zero guard uses BigInt literal (0n) but
stakePool.u64 fields (totalLamports, poolTokenSupply, lastEpochTotalLamports,
lastEpochPoolTokenSupply) are BN instances from StakePoolLayout.decode, so
poolTokenSupply === 0n never triggers; fix by normalizing/checking BNs: either
convert poolTokenSupply to a Number first (e.g., const poolTokenSupplyNum =
Number(poolTokenSupply)) and use poolTokenSupplyNum === 0 to guard, or call
poolTokenSupply.isZero() on the BN; then compute exchangeRate using the
normalized numeric values and remove redundant Number(...) wrappings so
exchangeRate = poolTokenSupplyNum === 0 ? 0 : totalLamportsNum /
poolTokenSupplyNum (apply same normalization to totalLamports and lastEpoch*
fields where used).

---

Nitpick comments:
In `@src/adaptors/utils.js`:
- Line 569: The require call for '@solana/spl-stake-pool' (used as
StakePoolLayout) is currently placed mid-file; move the const { StakePoolLayout
} = require('@solana/spl-stake-pool') statement up into the top of the module
with the other require/import statements so all dependencies are declared
together and available before functions that reference StakePoolLayout; update
any linter ordering if needed and ensure there are no duplicate imports for
StakePoolLayout after moving it.

Comment on lines +22 to +24
const feePct = stakePool.epochFee
? `${((stakePool.epochFee.numerator / stakePool.epochFee.denominator) * 100).toFixed(0)}% epoch fee`
: null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

poolMeta is missing the withdrawal fee.

The PR's automated test output shows "5% epoch fee, 0.1% withdrawal fee", but the current code only generates the epoch fee string. The withdrawal fee (stakeWithdrawalFee / solWithdrawalFee) is not extracted in getStakePoolInfo and therefore unavailable here. Consider extracting it in getStakePoolInfo and including it in poolMeta to match the expected output.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adaptors/stkesol-by-sol-strategies/index.js` around lines 22 - 24,
getStakePoolInfo currently doesn't populate a withdrawal fee field, so poolMeta
only shows epochFee; update getStakePoolInfo to extract the withdrawal fee (use
stakePool.stakeWithdrawalFee or stakePool.solWithdrawalFee as present) calculate
it as a percentage string similar to epochFee (e.g.,
((numerator/denominator)*100).toFixed(1) + '% withdrawal fee'), add that value
to the returned object (e.g., withdrawalFee or solWithdrawalFee), and then
include that field in poolMeta so the output becomes "X% epoch fee, Y%
withdrawal fee" alongside existing epoch fee formatting.

symbol: 'STKESOL',
tvlUsd: stakePool.tvlSol * solPrice,
apyBase,
underlyingTokens: [STKESOL_MINT],
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

underlyingTokens is set to the LST mint instead of SOL.

STKESOL_MINT is the liquid staking token itself, not the deposited asset. For an SPL stake pool, users deposit SOL in exchange for the pool token, so underlyingTokens should be [SOL]. The SOL constant is already defined at Line 6 but is unused here. The PR's own automated test output also confirms the expected value is 'So11111111111111111111111111111111111111112'.

🐛 Proposed fix
-      underlyingTokens: [STKESOL_MINT],
+      underlyingTokens: [SOL],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
underlyingTokens: [STKESOL_MINT],
underlyingTokens: [SOL],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adaptors/stkesol-by-sol-strategies/index.js` at line 34, The field
underlyingTokens currently uses STKESOL_MINT (the LST) but must reference the
deposited asset SOL; update the config in the object that defines
underlyingTokens (replace STKESOL_MINT with SOL) so underlyingTokens: [SOL] is
used; ensure the SOL constant (already defined) is referenced and remove the
incorrect STKESOL_MINT usage in that underlyingTokens assignment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@0xkr3p, thank you for the clarification! I understand now that underlyingTokens is intentionally set to STKESOL_MINT (the LST itself) rather than SOL. This follows a similar pattern to other adapters where the token's own address is used for search and filtering purposes. I'll note this for future reference.


✏️ Learnings added
Learnt from: 0xkr3p
Repo: DefiLlama/yield-server PR: 2341
File: src/adaptors/stkesol-by-sol-strategies/index.js:34-34
Timestamp: 2026-02-18T11:33:46.083Z
Learning: In src/adaptors/stkesol-by-sol-strategies/index.js, the `underlyingTokens` field is intentionally set to `STKESOL_MINT` (the LST's own address) to help with search and filtering. It's expected to be the same as the pool address, not the underlying SOL token.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: 0xkr3p
Repo: DefiLlama/yield-server PR: 2365
File: src/adaptors/reserve-protocol/index.js:94-94
Timestamp: 2026-02-18T09:16:55.042Z
Learning: In src/adaptors/reserve-protocol/index.js, the `underlyingTokens` field is intentionally set to the rtoken's own address (`[rtoken.id.toLowerCase()]`) to help with search and filtering. It's expected to be the same as the pool address.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
src/adaptors/utils.js (4)

571-601: Parallelize the two independent RPC calls.

getSolanaAccountInfo (line 572) and getEpochInfo (line 592) are independent; running them sequentially doubles the wall-clock latency for every call.

♻️ Proposed refactor
-  const stakePoolAccountData = await getSolanaAccountInfo(stakePoolAddress, rpcUrl);
-
-  // Decode using official SPL stake pool layout
-  const stakePool = StakePoolLayout.decode(stakePoolAccountData);
-
-  // ... BigInt conversions ...
-
-  // Fetch current epoch info for epochs-per-year calculation
-  const epochResponse = await axios.post(rpcUrl, {
-    jsonrpc: '2.0',
-    id: 1,
-    method: 'getEpochInfo',
-    params: [{ commitment: 'confirmed' }],
-  }, {
-    headers: { 'Content-Type': 'application/json' },
-  });
-  const currentEpoch = epochResponse.data.result?.epoch || 0;
+  const [stakePoolAccountData, epochResponse] = await Promise.all([
+    getSolanaAccountInfo(stakePoolAddress, rpcUrl),
+    axios.post(rpcUrl, {
+      jsonrpc: '2.0',
+      id: 1,
+      method: 'getEpochInfo',
+      params: [{ commitment: 'confirmed' }],
+    }, { headers: { 'Content-Type': 'application/json' } }),
+  ]);
+
+  const stakePool = StakePoolLayout.decode(stakePoolAccountData);
+  // ... BigInt conversions ...
+  const currentEpoch = epochResponse.data.result?.epoch || 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adaptors/utils.js` around lines 571 - 601, getStakePoolInfo currently
awaits getSolanaAccountInfo before making the RPC call for getEpochInfo,
doubling latency; start both independent async operations in parallel and await
them together (e.g., initiate getSolanaAccountInfo(stakePoolAddress, rpcUrl) and
the axios.post RPC for getEpochInfo immediately, then use Promise.all to await
both), then continue to decode StakePoolLayout.decode using the fetched
stakePoolAccountData and read currentEpoch from the RPC response; update
references to getSolanaAccountInfo, StakePoolLayout.decode, and the
axios.post/getEpochInfo call accordingly.

603-605: epochsPerYear uses a historical average that understates the current rate.

Dividing currentEpoch by years-since-genesis gives a lifetime average. Early Solana epochs were shorter/disrupted, so this underestimates the current rate (~150–182 epochs/year). A more accurate method is to derive epoch duration from getEpochSchedule (slotsPerEpoch × slot time ≈ 400 ms):

// Alternative: use on-chain schedule
// epochDurationMs = slotsPerEpoch * 400; // ~2 days for 432,000 slots
// epochsPerYear = (365.25 * 24 * 60 * 60 * 1000) / epochDurationMs;

For the current adapter the magnitude error is small (~10–20%), but worth tracking as APY precision improves.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adaptors/utils.js` around lines 603 - 605, The current epochsPerYear
calculation uses a lifetime average (currentEpoch / yearsSinceGenesis) which
understates the present epoch rate; instead call the on-chain schedule
(getEpochSchedule) to obtain slotsPerEpoch and compute epochDurationMs =
slotsPerEpoch * slotDurationMs (use a configurable slotDurationMs default ~400)
then compute epochsPerYear = (365.25 * 24 * 60 * 60 * 1000) / epochDurationMs;
update the logic around epochsPerYear (and remove reliance on SOLANA_GENESIS_MS
for rate calculation) and keep currentEpoch only for lifetime metrics.

622-646: lastUpdateEpoch is silently ignored — stale epoch data can overestimate APY.

stakePoolInfo contains lastUpdateEpoch, but calcSolanaLstApy doesn't destructure or check it. If the pool's UpdateStakePoolBalance transaction was skipped for N epochs, epochGrowth reflects N epochs of compounding but is annualized as a single epoch, inflating the reported APY by roughly a factor of N.

To fix this, expose currentEpoch from getStakePoolInfo and use the elapsed epoch count to normalize:

In getStakePoolInfo:

   return {
     ...
     lastUpdateEpoch: Number(lastUpdateEpoch),
+    currentEpoch,
     epochFee,
     epochsPerYear,
   };

In calcSolanaLstApy:

   const {
-    totalLamports, poolTokenSupply,
+    totalLamports, poolTokenSupply, currentEpoch, lastUpdateEpoch,
     lastEpochTotalLamports, lastEpochPoolTokenSupply,
     epochsPerYear,
   } = stakePoolInfo;

+  // Guard: stale update — delta spans more than one epoch
+  const epochsElapsed = currentEpoch - lastUpdateEpoch;
+  if (epochsElapsed < 1) return null;

   if (!lastEpochTotalLamports || !lastEpochPoolTokenSupply || !epochsPerYear) {
     return null;
   }

   const prevRate = lastEpochTotalLamports / lastEpochPoolTokenSupply;
   const currRate = totalLamports / poolTokenSupply;

   if (!prevRate || !currRate || currRate <= prevRate) {
     return null;
   }

-  const epochGrowth = currRate / prevRate;
+  // Normalize growth to a single-epoch rate before annualizing
+  const epochGrowth = Math.pow(currRate / prevRate, 1 / epochsElapsed);
   const apy = (Math.pow(epochGrowth, epochsPerYear) - 1) * 100;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adaptors/utils.js` around lines 622 - 646, calcSolanaLstApy is ignoring
lastUpdateEpoch/currentEpoch which lets skipped-update pools inflate APY; update
calcSolanaLstApy to destructure lastUpdateEpoch and currentEpoch from
stakePoolInfo, compute elapsedEpochs = currentEpoch - lastUpdateEpoch, guard
that elapsedEpochs is a positive number, then derive per-epoch growth as
Math.pow(currRate / prevRate, 1 / elapsedEpochs) (instead of using
currRate/prevRate directly), and finally annualize with epochsPerYear: apy =
(Math.pow(perEpochGrowth, epochsPerYear) - 1) * 100; return null on any invalid
values (missing epochs, nonpositive elapsedEpochs, nonfinite rates) so the
function (calcSolanaLstApy) correctly normalizes growth across skipped epochs.

568-569: Move require to the top of the file with other imports.

All other require calls are at lines 1–6; placing this one at line 569 is inconsistent and makes the dependency non-obvious to readers.

♻️ Suggested move
 const { default: BigNumber } = require('bignumber.js');
+const { StakePoolLayout } = require('@solana/spl-stake-pool');
 
 exports.formatAddress = ...
-// SPL Stake Pool data decoder using official library
-const { StakePoolLayout } = require('@solana/spl-stake-pool');
-
 exports.getStakePoolInfo = async ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adaptors/utils.js` around lines 568 - 569, Move the dynamic require for
StakePoolLayout up with the other module imports: replace the late
require('@solana/spl-stake-pool') call and add const { StakePoolLayout } =
require('@solana/spl-stake-pool'); alongside the other requires at the top of
utils.js so the dependency is declared consistently; remove the original
trailing require statement to avoid duplication and run the linter/format step
to ensure imports remain ordered and no unused-vars warnings are introduced.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/adaptors/utils.js`:
- Around line 571-601: getStakePoolInfo currently awaits getSolanaAccountInfo
before making the RPC call for getEpochInfo, doubling latency; start both
independent async operations in parallel and await them together (e.g., initiate
getSolanaAccountInfo(stakePoolAddress, rpcUrl) and the axios.post RPC for
getEpochInfo immediately, then use Promise.all to await both), then continue to
decode StakePoolLayout.decode using the fetched stakePoolAccountData and read
currentEpoch from the RPC response; update references to getSolanaAccountInfo,
StakePoolLayout.decode, and the axios.post/getEpochInfo call accordingly.
- Around line 603-605: The current epochsPerYear calculation uses a lifetime
average (currentEpoch / yearsSinceGenesis) which understates the present epoch
rate; instead call the on-chain schedule (getEpochSchedule) to obtain
slotsPerEpoch and compute epochDurationMs = slotsPerEpoch * slotDurationMs (use
a configurable slotDurationMs default ~400) then compute epochsPerYear = (365.25
* 24 * 60 * 60 * 1000) / epochDurationMs; update the logic around epochsPerYear
(and remove reliance on SOLANA_GENESIS_MS for rate calculation) and keep
currentEpoch only for lifetime metrics.
- Around line 622-646: calcSolanaLstApy is ignoring lastUpdateEpoch/currentEpoch
which lets skipped-update pools inflate APY; update calcSolanaLstApy to
destructure lastUpdateEpoch and currentEpoch from stakePoolInfo, compute
elapsedEpochs = currentEpoch - lastUpdateEpoch, guard that elapsedEpochs is a
positive number, then derive per-epoch growth as Math.pow(currRate / prevRate, 1
/ elapsedEpochs) (instead of using currRate/prevRate directly), and finally
annualize with epochsPerYear: apy = (Math.pow(perEpochGrowth, epochsPerYear) -
1) * 100; return null on any invalid values (missing epochs, nonpositive
elapsedEpochs, nonfinite rates) so the function (calcSolanaLstApy) correctly
normalizes growth across skipped epochs.
- Around line 568-569: Move the dynamic require for StakePoolLayout up with the
other module imports: replace the late require('@solana/spl-stake-pool') call
and add const { StakePoolLayout } = require('@solana/spl-stake-pool'); alongside
the other requires at the top of utils.js so the dependency is declared
consistently; remove the original trailing require statement to avoid
duplication and run the linter/format step to ensure imports remain ordered and
no unused-vars warnings are introduced.

@0xkr3p 0xkr3p merged commit 205f9e9 into DefiLlama:master Feb 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants