Skip to content

OPDATA-7578 Add strcUSX tranche exchange rate endpoint#5097

Open
magiodev-cll wants to merge 18 commits into
refactor/solana-exchange-rate-sharedfrom
feat/strcusx-tranche-exchange-rate
Open

OPDATA-7578 Add strcUSX tranche exchange rate endpoint#5097
magiodev-cll wants to merge 18 commits into
refactor/solana-exchange-rate-sharedfrom
feat/strcusx-tranche-exchange-rate

Conversation

@magiodev-cll

@magiodev-cll magiodev-cll commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a new strcusx-exchange-rate endpoint to solana-functions for Solstice strcUSX junior/senior tranche exchange rates.
  • Derive Controller, Strategy, Accounting, Junior Mint, and Senior Mint PDAs from programAddress and strategyName.
  • Decode raw Anchor/bytemuck account data and SPL mint data with bigint math.
  • Support both tranche: junior and tranche: senior using required jobspec bounds minRate / maxRate.
  • Return data.result, data.computedResult, per-tranche computed rates, decoded accounting values, and derived addresses for observability.

OPDATA

  • OPDATA-7578

Adapter Checklist

  • The base branch is main.
  • A changeset is included: .changeset/fresh-tigers-pay.md.
  • No package-manager or EA framework dependency bump is included; dependency/version changes are intentionally out of scope for this PR.
  • Input parameters are documented in the generated adapter README.
  • Unit and integration tests are included for the new endpoint.
  • Devnet live smoke tests were run against the deployed Solstice program.
  • No ea_context/ files are included in this PR.

Request Shape

{
  "data": {
    "endpoint": "strcusx-exchange-rate",
    "programAddress": "7iNvMc3x5VvwNmYomAAg86CpWeEw7QfDF2z5GgtDzHXe",
    "strategyName": "STRC-USX-1",
    "tranche": "junior",
    "minRate": "950000000000000000",
    "maxRate": "1050000000000000000"
  }
}

Devnet Smoke Test

  • Program: 7iNvMc3x5VvwNmYomAAg86CpWeEw7QfDF2z5GgtDzHXe
  • Strategy: STRC-USX-1
  • RPC: https://api.devnet.solana.com
  • Junior result: 1000000002222222222, statusCode: 200, boundsApplied: false
  • Senior result: 1000000000000000000, statusCode: 200, boundsApplied: false

Validation

  • yarn install --immutable
  • yarn workspace @chainlink/solana-functions-adapter build
  • yarn tsc -p packages/sources/solana-functions/tsconfig.test.json
  • yarn eslint packages/sources/solana-functions/src packages/sources/solana-functions/test (warnings only, existing repo warnings)
  • yarn prettier --check ...
  • yarn test packages/sources/solana-functions/test/unit/strcusx-exchange-rate.test.ts packages/sources/solana-functions/test/integration/strcusx-exchange-rate.test.ts --runInBand (18 passed, 3 snapshots)

Notes

  • This PR is independent from the stSLX PR and is based on main.
  • Bounds are explicit jobspec safeguards and are required request params for both tranches.

Automated EA Review and Live Evidence

  • Full redacted output from the experimental ea-review static + dynamic harness was posted in this PR comment: OPDATA-7578 Add strcUSX tranche exchange rate endpoint #5097 (comment)
  • Live endpoint tests were run against https://api.devnet.solana.com.
  • Live junior response returned statusCode: 200, result: 1000000002222222222, computedResult: 1000000002222222222, and boundsApplied: false.
  • Live senior response returned statusCode: 200, result: 1000000000000000000, computedResult: 1000000000000000000, and boundsApplied: false.

@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a60669c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@chainlink/solana-functions-adapter Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@magiodev-cll magiodev-cll changed the title Add strcUSX tranche exchange rate endpoint OPDATA-7578 Add strcUSX tranche exchange rate endpoint Jun 17, 2026
@magiodev-cll magiodev-cll marked this pull request as ready for review June 17, 2026 13:03
@magiodev-cll

magiodev-cll commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Spawning the local server:

➜  external-adapters-js git:(feat/strcusx-tranche-exchange-rate) ✗ RPC_URL=https://api.devnet.solana.com \
EA_HOST=127.0.0.1 \
EA_PORT=18080 \
METRICS_ENABLED=false \
yarn workspace @chainlink/solana-functions-adapter server:dist

{"level":"warn","time":1781698929645,"isoTime":"2026-06-17T12:22:09.645Z","pid":24412,"hostname":"MB-JYCTKQD9RJ","layer":"Adapter","msg":"METRICS_ENABLED has been set to false. Metrics should not be disabled in a production environment."}
(node:24412) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
{"level":"info","time":1781698929645,"isoTime":"2026-06-17T12:22:09.645Z","pid":24412,"hostname":"MB-JYCTKQD9RJ","layer":"CacheFactory","msg":"Using \"local\" cache."}
{"level":"info","time":1781698929678,"isoTime":"2026-06-17T12:22:09.678Z","pid":24412,"hostname":"MB-JYCTKQD9RJ","layer":"Main","msg":"Starting background execution loop"}
{"level":"info","time":1781698929682,"isoTime":"2026-06-17T12:22:09.682Z","pid":24412,"hostname":"MB-JYCTKQD9RJ","layer":"Main","msg":"Listening on port 18080"}

Testing junior strcUSXjr:

➜  external-adapters-js git:(feat/strcusx-tranche-exchange-rate) ✗ curl -sS -X POST http://127.0.0.1:18080 \
  -H 'Content-Type: application/json' \
  --data '{
    "data": {
      "endpoint": "strcusx-exchange-rate",
      "programAddress": "7iNvMc3x5VvwNmYomAAg86CpWeEw7QfDF2z5GgtDzHXe",
      "strategyName": "STRC-USX-1",
      "tranche": "junior",
      "minRate": "950000000000000000",
      "maxRate": "1050000000000000000"
    }
  }' | jq
{
  "data": {
    "result": "1000000002222222222",
    "computedResult": "1000000002222222222",
    "tranche": "junior",
    "decimals": 18,
    "minRate": "950000000000000000",
    "maxRate": "1050000000000000000",
    "boundsApplied": false
  },
  "statusCode": 200,
  "result": "1000000002222222222",
  "timestamps": {
    "providerDataRequestedUnixMs": 1781698936442,
    "providerDataReceivedUnixMs": 1781698936705
  }
}

Testing senior strcUSXsr:

➜  external-adapters-js git:(feat/strcusx-tranche-exchange-rate) ✗ curl -sS -X POST http://127.0.0.1:18080 \
  -H 'Content-Type: application/json' \
  --data '{
    "data": {
      "endpoint": "strcusx-exchange-rate",
      "programAddress": "7iNvMc3x5VvwNmYomAAg86CpWeEw7QfDF2z5GgtDzHXe",
      "strategyName": "STRC-USX-1",
      "tranche": "senior",
      "minRate": "950000000000000000",
      "maxRate": "1050000000000000000"
    }
  }' | jq
{
  "data": {
    "result": "1000000000000000000",
    "computedResult": "1000000000000000000",
    "tranche": "senior",
    "decimals": 18,
    "minRate": "950000000000000000",
    "maxRate": "1050000000000000000",
    "boundsApplied": false
  },
  "statusCode": 200,
  "result": "1000000000000000000",
  "timestamps": {
    "providerDataRequestedUnixMs": 1781698945597,
    "providerDataReceivedUnixMs": 1781698945716
  }
}

Comment thread packages/sources/solana-functions/src/transport/strcusx-exchange-rate.ts Outdated
Comment thread packages/sources/solana-functions/src/transport/strcusx-exchange-rate.ts Outdated
@magiodev-cll magiodev-cll force-pushed the feat/strcusx-tranche-exchange-rate branch from 2b9c47c to e87f379 Compare June 18, 2026 07:54
@magiodev-cll

Copy link
Copy Markdown
Contributor Author

Automated EA Review Harness Output + Live API Evidence

PR: #5097
Branch: feat/strcusx-tranche-exchange-rate
Commit tested: e87f37912
Adapter: @chainlink/solana-functions-adapter
Endpoint: strcusx-exchange-rate

Static Harness Command

EA_REVIEW_NO_TELEMETRY=1 node <ea-review>/dist/cli.js scan \
  -a solana-functions \
  -r <external-adapters-js> \
  -p ci-fast \
  --severity critical,high \
  -f markdown

Static Harness Output

EA Review Results

Profile: ci-fast | Adapters: solana-functions | Duration: 87ms

Summary

Metric Count
Total 74
Passed 28
Failed 46
Critical 5
High 41

solana-functions

🔴 DATA-002 NaN/Infinity Result `src/transport/eusx-price.ts:90`

Severity: critical
Message: Division operation without NaN/Infinity guard; add isFinite() check or guard before computing
Why: NaN or Infinity propagating as a price result causes the Chainlink node to submit invalid data on-chain, potentially triggering incorrect liquidations or contract behavior
Fix: Add Number.isNaN() and Number.isFinite() checks after numeric operations before returning results

numerator / denominator
🔴 DATA-002 NaN/Infinity Result `src/transport/sanctum-infinity.ts:76`

Severity: critical
Message: Division operation without NaN/Infinity guard; add isFinite() check or guard before computing
Why: NaN or Infinity propagating as a price result causes the Chainlink node to submit invalid data on-chain, potentially triggering incorrect liquidations or contract behavior
Fix: Add Number.isNaN() and Number.isFinite() checks after numeric operations before returning results

Number(totalPoolValueString.result) / Number(totalPoolTokenSupplyString.result)
🔴 DATA-002 NaN/Infinity Result `src/transport/sanctum-infinity.ts:76`

Severity: critical
Message: Number() call without NaN validation; add Number.isNaN() check or return null/default if invalid
Why: NaN or Infinity propagating as a price result causes the Chainlink node to submit invalid data on-chain, potentially triggering incorrect liquidations or contract behavior
Fix: Add Number.isNaN() and Number.isFinite() checks after numeric operations before returning results

Number(totalPoolValueString.result)
🔴 DATA-002 NaN/Infinity Result `src/transport/sanctum-infinity.ts:76`

Severity: critical
Message: Number() call without NaN validation; add Number.isNaN() check or return null/default if invalid
Why: NaN or Infinity propagating as a price result causes the Chainlink node to submit invalid data on-chain, potentially triggering incorrect liquidations or contract behavior
Fix: Add Number.isNaN() and Number.isFinite() checks after numeric operations before returning results

Number(totalPoolTokenSupplyString.result)
🔴 DATA-002 NaN/Infinity Result `src/transport/strcusx-exchange-rate.ts:420`

Severity: critical
Message: Division operation without NaN/Infinity guard; add isFinite() check or guard before computing
Why: NaN or Infinity propagating as a price result causes the Chainlink node to submit invalid data on-chain, potentially triggering incorrect liquidations or contract behavior
Fix: Add Number.isNaN() and Number.isFinite() checks after numeric operations before returning results

(assets * 10n ** BigInt(RESULT_DECIMALS + trancheMintDecimals)) /
    (shares * 10n ** BigInt(assetMintDecimals))
🟠 SEC-007 Prototype Pollution `src/shared/account-reader.ts:16`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

  ): Promise<T> {
    const [pda] = await getProgramDerivedAddress({
      programAddress,
🟠 SEC-007 Prototype Pollution `src/transport/eusx-price.ts:99`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

    const programAddress = params.address as Address
    const [vestingSchedule, yieldPool] = await Promise.all([
      accountReader.fetchAccountInformationByAddressAndSeeds<VestingSchedule>(
🟠 SEC-007 Prototype Pollution `src/transport/extension.ts:54`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

    const extensionData = buffer.slice(offset, offset + extensionLength)
    extensionDataByType[extensionType] = extensionData
    offset += extensionLength
🟠 SEC-007 Prototype Pollution `src/transport/extension.ts:119`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

      for (const baseField of params.baseFields) {
        resultData[baseField.name] = getFieldFromBuffer(baseField, data)
      }
🟠 SEC-007 Prototype Pollution `src/transport/extension.ts:127`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

      for (const extensionField of params.extensionFields) {
        resultData[extensionField.name] = getExtensionField(extensionField, extensionDataByType)
      }
🟠 SEC-007 Prototype Pollution `src/transport/extension.ts:132`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

    if (params.resultDecimals !== undefined) {
      resultData['decimals'] = params.resultDecimals
    }
🟠 SEC-007 Prototype Pollution `src/transport/sanctum-infinity.ts:62`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

    const [totalPoolValueString, totalPoolTokenSupplyString] = await Promise.all([
      fetchFieldFromBufferLayoutStateAccount({
🟠 SEC-007 Prototype Pollution `src/transport/strcusx-exchange-rate.ts:238`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

export const deriveControllerAddress = (programAddress: string) => {
  const [address] = PublicKey.findProgramAddressSync(
    [Buffer.from('CONTROLLER')],
🟠 SEC-007 Prototype Pollution `src/transport/strcusx-exchange-rate.ts:246`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

export const deriveStrategyAddress = (programAddress: string, strategyName: string) => {
  const [address] = PublicKey.findProgramAddressSync(
    [Buffer.from('STRATEGY'), Buffer.from(strategyName)],
🟠 SEC-007 Prototype Pollution `src/transport/strcusx-exchange-rate.ts:254`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

export const deriveAccountingAddress = (programAddress: string, strategyName: string) => {
  const [address] = PublicKey.findProgramAddressSync(
    [Buffer.from('ACCOUNTING'), Buffer.from(strategyName)],
🟠 SEC-007 Prototype Pollution `src/transport/strcusx-exchange-rate.ts:262`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

export const deriveJuniorMintAddress = (programAddress: string, strategyName: string) => {
  const [address] = PublicKey.findProgramAddressSync(
    [Buffer.from('JUNIOR_MINT'), Buffer.from(strategyName)],
🟠 SEC-007 Prototype Pollution `src/transport/strcusx-exchange-rate.ts:270`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

export const deriveSeniorMintAddress = (programAddress: string, strategyName: string) => {
  const [address] = PublicKey.findProgramAddressSync(
    [Buffer.from('SENIOR_MINT'), Buffer.from(strategyName)],
🟠 SEC-007 Prototype Pollution `src/transport/strcusx-exchange-rate.ts:536`

Severity: high
Message: Detects potential prototype pollution via dynamic property assignment
Why: Prototype pollution can corrupt shared JavaScript objects, potentially altering price data across all adapter responses or bypassing validation logic
Fix: Validate property keys against a whitelist before dynamic assignment; use Object.create(null) for dictionaries

    const [assetMintAccount] = await this.fetchAccounts([controller.assetMintAddress])
🟠 CFG-001 Missing Rate Limiting `src/index.ts:1`

Severity: high
Message: Missing rate limiting for http transport; add rateLimiting config to prevent API quota exhaustion
Why: Missing rate limiting on HTTP/REST transports can exhaust API quotas, causing cascading failures. Streaming transports (WebSocket, batch) don't hit per-request API quotas and are exempt.
Fix: Add rateLimiting configuration to HTTP/REST adapters; WebSocket/batch adapters can safely omit this

🟠 DATA-001 Missing Error Response `src/transport/anchor-data.ts:39`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

  async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
    await Promise.all(entries.map(async (param) => this.handleRequest(param)))
    await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
🟠 DATA-001 Missing Error Response `src/transport/buffer-layout.ts:29`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

  async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
    await Promise.all(entries.map(async (param) => this.handleRequest(param)))
    await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
🟠 DATA-001 Missing Error Response `src/transport/buffer-layout.ts:59`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

    const result = await fetchFieldFromBufferLayoutStateAccount({
      stateAccountAddress: params.stateAccountAddress,
🟠 DATA-001 Missing Error Response `src/transport/eusx-price.ts:61`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

  ) {
    await Promise.all(entries.map(async (param) => this.handleRequest(param)))
    await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
🟠 DATA-001 Missing Error Response `src/transport/eusx-price.ts:99`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

    const programAddress = params.address as Address
    const [vestingSchedule, yieldPool] = await Promise.all([
      accountReader.fetchAccountInformationByAddressAndSeeds<VestingSchedule>(
🟠 DATA-001 Missing Error Response `src/transport/extension.ts:80`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

  async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
    await Promise.all(entries.map(async (param) => this.handleRequest(param)))
    await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
🟠 DATA-001 Missing Error Response `src/transport/pool-token-rate.ts:31`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

  async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
    await Promise.all(entries.map(async (param) => this.handleRequest(param)))
    await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
🟠 DATA-001 Missing Error Response `src/transport/pool-token-rate.ts:61`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

    const { programAddress, data } = await fetchDataFromBufferLayoutStateAccount({
      stateAccountAddress: params.stakePoolAccountAddress,
🟠 DATA-001 Missing Error Response `src/transport/sanctum-infinity.ts:32`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

  async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
    await Promise.all(entries.map(async (param) => this.handleRequest(param)))
    await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
🟠 DATA-001 Missing Error Response `src/transport/sanctum-infinity.ts:62`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

    const [totalPoolValueString, totalPoolTokenSupplyString] = await Promise.all([
      fetchFieldFromBufferLayoutStateAccount({
🟠 DATA-001 Missing Error Response `src/transport/strcusx-exchange-rate.ts:439`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

  async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
    await Promise.all(entries.map(async (param) => this.handleRequest(param)))
    await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
🟠 DATA-001 Missing Error Response `src/transport/strcusx-exchange-rate.ts:493`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

      clockAccount,
    ] = await this.fetchAccounts([
      controllerAddress,
🟠 DATA-001 Missing Error Response `src/transport/strcusx-exchange-rate.ts:536`

Severity: high
Message: Network/API call without try-catch error handling
Why: Unhandled API errors crash the adapter or return undefined results to the Chainlink node, causing failed oracle reports and potential node penalties
Fix: Wrap all network/API calls in try-catch blocks and return proper error responses

    const [assetMintAccount] = await this.fetchAccounts([controller.assetMintAddress])
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/anchor-data.ts:56`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/anchor-data.ts:117`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/buffer-layout.ts:46`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/buffer-layout.ts:76`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/eusx-price.ts:78`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/eusx-price.ts:165`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/extension.ts:97`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/extension.ts:142`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/pool-token-rate.ts:48`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/pool-token-rate.ts:92`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/sanctum-infinity.ts:49`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/sanctum-infinity.ts:87`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 ERR-002 Zero/Null Timestamp in Error Response `src/transport/strcusx-exchange-rate.ts:456`

Severity: high
Message: Timestamp set to undefined in response — can corrupt cache TTL
Why: Generic error handling without distinguishing error types (network vs parsing vs validation) prevents proper recovery strategies and makes debugging harder
Fix: Always return Date.now() as timestamps in error responses to prevent cache TTL corruption

providerIndicatedTimeUnixMs: undefined
🟠 CHAIN-001 RPC Timeout `src/transport/strcusx-exchange-rate.ts:1`

Severity: high
Message: RPC provider configured without explicit timeout — calls can hang indefinitely
Why: RPC calls without timeout can hang indefinitely when a node is unresponsive, blocking the adapter.
Fix: Add explicit timeout to JsonRpcProvider or ethers provider configuration

Dynamic Harness Command

EA_REVIEW_NO_TELEMETRY=1 node <ea-review>/dist/cli.js scan \
  -a solana-functions \
  -r <external-adapters-js> \
  -p dynamic-only \
  --dynamic \
  --dynamic-port 18297 \
  --dynamic-env RPC_URL=https://api.devnet.solana.com \
  -f markdown

Dynamic Harness Output

EA Review Results

Profile: dynamic-only | Adapters: solana-functions | Duration: 5.2s

Summary

Metric Count
Total 12
Passed 8
Failed 4
High 3
Medium 1

solana-functions

🟠 DYN-002 Runtime Price Validation

Severity: high
Message: Expected status 200, got 404
Why: Price feeds are the core function of an EA — a request that returns non-numeric or non-positive data will poison oracle reports
Fix: Verify the adapter's default endpoint handles standard base/quote pairs and returns a numeric result > 0

🟠 DYN-006 Data Quality Bounds

Severity: high
Message: Non-200 response: 404
Why: Feeding NaN, Infinity, or extreme values into an oracle report can corrupt on-chain price feeds and trigger liquidations or arbitrage exploits
Fix: Add result validation that rejects non-finite, non-positive, or unreasonably large numbers before returning

🟠 DYN-012 Error Recovery

Severity: high
Message: Adapter returned 404 after recovery attempt
Why: If a single bad request leaves the adapter in a broken state, all subsequent requests from every node will fail until the process is restarted
Fix: Ensure error paths do not corrupt shared state — use try/catch around request handling and reset any per-request state on failure

🟡 DYN-009 Response Consistency

Severity: medium
Message: Non-numeric results: undefined, undefined
Why: Large swings between identical requests may indicate race conditions, stale cache issues, or unstable upstream data
Fix: Investigate why consecutive identical requests return significantly different results — check caching and data source stability

Live Endpoint Runtime Command

EA_PORT=18207 \
RPC_URL=https://api.devnet.solana.com \
METRICS_ENABLED=false \
yarn node -e "require('./dist/index.js').server()"

Live Endpoint Request/Response

{
  "rpcUrl": "https://api.devnet.solana.com",
  "responses": [
    {
      "label": "junior",
      "request": {
        "data": {
          "endpoint": "strcusx-exchange-rate",
          "programAddress": "7iNvMc3x5VvwNmYomAAg86CpWeEw7QfDF2z5GgtDzHXe",
          "strategyName": "STRC-USX-1",
          "tranche": "junior",
          "minRate": "950000000000000000",
          "maxRate": "1050000000000000000"
        }
      },
      "response": {
        "statusCode": 200,
        "elapsedMs": 1409,
        "body": {
          "data": {
            "result": "1000000002222222222",
            "computedResult": "1000000002222222222",
            "tranche": "junior",
            "decimals": 18,
            "minRate": "950000000000000000",
            "maxRate": "1050000000000000000",
            "boundsApplied": false,
            "vestedTotalAssets": "650000001",
            "vestedSeniorAssets": "200000000",
            "vestedJuniorAssets": "450000001",
            "seniorShares": "200000000",
            "juniorShares": "450000000",
            "unvestedTotalAssets": "0",
            "unvestedSeniorAssets": "0"
          },
          "statusCode": 200,
          "result": "1000000002222222222",
          "timestamps": {
            "providerDataRequestedUnixMs": 1782131031969,
            "providerDataReceivedUnixMs": 1782131032284,
            "providerIndicatedTimeUnixMs": 1782131030000
          }
        }
      }
    },
    {
      "label": "senior",
      "request": {
        "data": {
          "endpoint": "strcusx-exchange-rate",
          "programAddress": "7iNvMc3x5VvwNmYomAAg86CpWeEw7QfDF2z5GgtDzHXe",
          "strategyName": "STRC-USX-1",
          "tranche": "senior",
          "minRate": "950000000000000000",
          "maxRate": "1050000000000000000"
        }
      },
      "response": {
        "statusCode": 200,
        "elapsedMs": 1005,
        "body": {
          "data": {
            "result": "1000000000000000000",
            "computedResult": "1000000000000000000",
            "tranche": "senior",
            "decimals": 18,
            "minRate": "950000000000000000",
            "maxRate": "1050000000000000000",
            "boundsApplied": false,
            "vestedTotalAssets": "650000001",
            "vestedSeniorAssets": "200000000",
            "vestedJuniorAssets": "450000001",
            "seniorShares": "200000000",
            "juniorShares": "450000000",
            "unvestedTotalAssets": "0",
            "unvestedSeniorAssets": "0"
          },
          "statusCode": 200,
          "result": "1000000000000000000",
          "timestamps": {
            "providerDataRequestedUnixMs": 1782131033297,
            "providerDataReceivedUnixMs": 1782131033412,
            "providerIndicatedTimeUnixMs": 1782131032000
          }
        }
      }
    }
  ]
}

@magiodev-cll

Copy link
Copy Markdown
Contributor Author

Follow-up: EA Review Finding Scope

The automated ea-review harness scans the full existing solana-functions adapter, while this PR only adds/registers the new strcusx-exchange-rate endpoint. I split the posted harness findings by whether they are in code introduced by this PR versus pre-existing adapter files.

Static Harness Scope

Static harness failures reported: 46

Classification Count Notes
Findings in the new strcUSX transport 12 These are the only findings directly in newly added endpoint code.
Adapter entrypoint heuristic 1 src/index.ts was touched to register the endpoint, but the finding is adapter-wide.
Pre-existing solana-functions files 33 These are outside this PR's endpoint implementation.

Findings directly in new strcUSX code:

Rule Location
DATA-002 src/transport/strcusx-exchange-rate.ts:420
SEC-007 src/transport/strcusx-exchange-rate.ts:238
SEC-007 src/transport/strcusx-exchange-rate.ts:246
SEC-007 src/transport/strcusx-exchange-rate.ts:254
SEC-007 src/transport/strcusx-exchange-rate.ts:262
SEC-007 src/transport/strcusx-exchange-rate.ts:270
SEC-007 src/transport/strcusx-exchange-rate.ts:536
DATA-001 src/transport/strcusx-exchange-rate.ts:439
DATA-001 src/transport/strcusx-exchange-rate.ts:493
DATA-001 src/transport/strcusx-exchange-rate.ts:536
ERR-002 src/transport/strcusx-exchange-rate.ts:456
CHAIN-001 src/transport/strcusx-exchange-rate.ts:1

Touched but not specific to the new endpoint:

Rule Location Why it is adapter-wide
CFG-001 src/index.ts:1 This flags adapter-level rate limiting. This PR only touches src/index.ts to register the endpoint.

Pre-existing files with findings:

  • src/transport/eusx-price.ts
  • src/transport/sanctum-infinity.ts
  • src/shared/account-reader.ts
  • src/transport/extension.ts
  • src/transport/anchor-data.ts
  • src/transport/buffer-layout.ts
  • src/transport/pool-token-rate.ts

Dynamic Harness Scope

Dynamic harness failures reported:

  • DYN-002
  • DYN-006
  • DYN-009
  • DYN-012

These are not clearly caused by the new strcUSX endpoint. The experimental dynamic harness sends generic/default adapter requests and received 404 / non-numeric responses for value-oriented scenarios. The endpoint-specific live requests in the previous comment are the stronger runtime evidence for this PR and returned statusCode: 200 for both junior and senior, with result == computedResult and boundsApplied: false.

Summary

Most harness findings are from pre-existing solana-functions code. The PR-relevant static findings are limited to the new strcusx-exchange-rate transport plus one adapter-wide entrypoint heuristic. The live endpoint tests confirm both tranche paths return successful responses against the actual Solana devnet RPC path.

@magiodev-cll

Copy link
Copy Markdown
Contributor Author

Follow-up: Why the PR-local EA review findings are no-fix

This is a concise rationale for the findings that landed in files introduced or touched by this PR. I am not treating all findings as generic false positives; some are real-ish but adapter-wide or cross-cutting. I do not see a required endpoint code change for this PR.

Finding Scope Decision Rationale
DATA-002 at src/transport/strcusx-exchange-rate.ts:420 New strcUSX transport No fix calculateRate uses bigint, not JS number, so it cannot produce NaN or Infinity. Division-by-zero is handled by if (shares === 0n) return null, and the selected null rate is converted to an adapter error before returning.
SEC-007 at src/transport/strcusx-exchange-rate.ts:238, :246, :254, :262, :270, and :536 New strcUSX transport No fix These are tuple/array destructuring patterns from PDA derivation helpers and account fetch results. Prototype pollution requires dynamic object-key writes such as obj[userInput] = value; that is not happening here.
DATA-001 at src/transport/strcusx-exchange-rate.ts:439, :493, and :536 New strcUSX transport No fix The scanner sees async/RPC calls, but this transport follows the existing solana-functions pattern: backgroundHandler calls handleRequest, and handleRequest wraps _handleRequest in try/catch and writes an adapter error response.
ERR-002 at src/transport/strcusx-exchange-rate.ts:456 New strcUSX transport error path No fix This is the error response path, where there is no reliable provider-indicated timestamp. Successful strcUSX responses do include Solana Clock-derived providerIndicatedTimeUnixMs, which is the important OCR-convergence path.
CHAIN-001 at src/transport/strcusx-exchange-rate.ts:1 Shared RPC behavior surfaced in new file No fix in this PR This is the one finding I would consider real engineering debt, but it belongs in shared SolanaRpcFactory/adapter config work because every Solana endpoint uses the same RPC factory. A one-off endpoint patch would be inconsistent.
CFG-001 at src/index.ts:1 Adapter entrypoint touched for endpoint registration No fix in this PR The finding is adapter-wide rate-limiting heuristics. This PR only touches src/index.ts to register strcusxExchangeRate; it does not introduce a new external HTTP provider quota path.

Dynamic harness note: the DYN-* failures came from generic/default adapter requests returning 404 or non-numeric results. The endpoint-specific live requests for both junior and senior returned HTTP 200 with result == computedResult and boundsApplied: false, so I do not treat those generic dynamic failures as endpoint bugs.

Suggested follow-up outside this PR: if the team wants to address the most legitimate scanner concern, create a separate shared task for explicit Solana RPC timeout support in SolanaRpcFactory.

@mohamed-mehany mohamed-mehany left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary

Thanks @magiodev-cll for this and I went through the logic and I believe it maybe functional and matches the methodology in OPDATA-7578.
The problem is the state of the code, it's quite hard to read and review, and it reads as largely LLM-generated. That's fine as a starting point, but for this to meet Chainlink standards it needs to follow our best practices, reuse the abstractions we already have, and be reliable/maintainable enough for the next person to review and own. Right now it forks its own parallel idiom instead of building on the adapter.That said, the state of the code makes it very hard to review and maintain, and it reads as largely LLM-generated. That's acceptable as a draft, but to meet Chainlink standards it needs to follow our best practices, reuse existing abstractions, and be reliable enough for someone else to own. The 658-line transport is roughly 6× a comparable endpoint here, mostly from duplicated scaffolding and an inline hand-rolled decoder.

Please refactor, and when you do, explicitly instruct the model to follow best practices, prefer existing abstractions over reinventing them, and avoid code smells. For large PRs like this, please use a stronger model (ideally Opus 4.8 or equivalent). The final version should have no magic numbers or arbitrary hardcoded values, every offset, discriminator, and decimal must be named and traceable to the program IDL.

Main issue: brittle manual decoding (highest priority)

  • Account fields are read with bare byte offsets and hardcoded discriminators (readPublicKey(data, 47), readPublicKey(data, 79), readU128LE(data, 73), data.readUInt8(239), etc.). Any program redeploy that reorders or pads a field silently returns a garbage rate. This is the kind of magic-number code we can't accept.
    • Decode via the program IDL using the adapter's existing SolanaAccountReader (BorshCoder).
    • If these are zero-copy/bytemuck accounts that Borsh can't decode reliably (likely why it was hand-rolled), use a typed @solana/buffer-layout struct like shared/buffer-layout-accounts.ts does for Sanctum, with named fields, not scattered offsets.
  • Commit the program IDL to src/idl/. The IDL attached to the ticket was never added to the repo, so none of the offsets/discriminators are reviewable. Please add it.

Code quality / structure

  • De-duplicate shared helpers. parseRateBound, getAccountDataBuffer, assertOwnerProgram/assertTokenProgramOwner, the bounds-clamp, and the shared types are copy-pasted verbatim from the stSLX PR. Extract them into src/shared/.
  • Reuse existing abstractions instead of re-implementing raw getMultipleAccounts + buffer slicing with hand-rolled RPC response types (MultipleAccountsRpcResponse, as casts).
  • Stick to one Solana stack: new code mixes legacy @solana/web3.js into the adapter's modern @solana/addresses / @solana/rpc idiom.

Specific issues

  • tranche is a free type: 'string' validated by a hand-written parseTranche. Use the framework's InputParameters options: ['junior', 'senior'] enum, it validates at the framework layer and self-documents in the README.
  • ASSET_MINT_DECIMALS = 6 is asserted against the decoded mint, then the constant (not the decoded assetMint.decimals) is passed into calculateRate. Use the decoded value for consistency, and document why 6 is expected.
  • Bounds-clamp is an inline nested ternary duplicated from the other PR, extract to a single named helper.
  • boundsApplied uses string comparison; compare bigints instead.
  • General: every constant (PDA seeds, discriminators, offsets, decimals) must be named and traceable, no inline literals in the decode path.

Happy to review iteratively

@mohamed-mehany

Copy link
Copy Markdown
Collaborator

Suggestion: extract a shared-components PR and stack these on top

Stepping back from the per-PR comments, both PRs (#5095 (stSLX) and #5097 (strcUSX)) and endpoints duplicate a substantial amount of the same code (parseRateBound, getAccountDataBuffer, assertOwnerProgram/assertTokenProgramOwner, the min/max bounds-clamp, the
shared AccountInfo/MintInfo types, and the raw account-read plumbing).

Rather than refactoring each PR independently and reconciling the two copies later, it
would likely be cleaner to:

  1. Open a third PR first that lands the shared building blocks in
    src/shared/, e.g. rate-bound parsing/validation, the bounds-clamp helper,
    account-buffer extraction + owner/length assertions, and (ideally) IDL/typed
    buffer-layout decoding built on the existing SolanaAccountReader /
    buffer-layout-accounts.ts. This PR has no new endpoint and is small and easy
    to review on its own.
  2. Rebase/stack OPDATA-7578 Add stSLX exchange rate endpoint #5095 (stSLX) and OPDATA-7578 Add strcUSX tranche exchange rate endpoint #5097 (strcUSX) on top of it, so each
    endpoint PR shrinks to just its endpoint definition, its decode layout, and its
    rate formula, no duplicated scaffolding.

Benefits:

  • One canonical implementation of the shared logic instead of two copies that can
    drift.
  • Each endpoint PR becomes much smaller and genuinely reviewable.
  • The shared module can be unit-tested once, in isolation.
  • Future Solana exchange-rate endpoints get the same foundation for free.

@mohamed-mehany

mohamed-mehany commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

To make the previous two points concrete on how to structure the refactor:

Keep this as its own endpoint, but move the common logic into a shared library under
src/shared/ instead of carrying it inline. Specifically, pull out into small,
individually unit-tested utils:

  • the linear-vesting math (unvested = amount * (end − now) / (end − start)), shared with eUSX
  • the 18-decimal rate normalization (assets / shares)
  • the min/max bounds clamp + boundsApplied
  • an atomic getMultipleAccounts read helper

With those in place, this transport reduces to just: derive PDAs -> atomic read ->
decode via IDL/Borsh (reusing account-reader) -> call the shared utils -> return. That
should take it from ~650 lines to something reviewable, and it removes the hand-rolled
offset decoding.

Ideally land the shared utils + IDL as a small base PR first, then rebase this one on
top so the diff is only the strcUSX-specific addresses, decode layout, and tranche
formula. #5095 (stSLX) can stack on the same base.

@magiodev-cll magiodev-cll force-pushed the feat/strcusx-tranche-exchange-rate branch from 0f15faa to e0e4f94 Compare June 24, 2026 12:33
@magiodev-cll magiodev-cll changed the base branch from main to refactor/solana-exchange-rate-shared June 24, 2026 12:33
@magiodev-cll magiodev-cll force-pushed the feat/strcusx-tranche-exchange-rate branch from 93ec3a7 to 9cabe24 Compare June 24, 2026 15:04
@danwilliams-cll danwilliams-cll requested a review from Copilot June 24, 2026 15:59

Copilot AI left a comment

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.

Pull request overview

Adds a new strcusx-exchange-rate endpoint to the solana-functions adapter to compute Solstice strcUSX junior/senior tranche exchange rates from on-chain accounts (PDAs + SPL mints), including vesting-aware “book value” adjustments and min/max bounds clamping.

Changes:

  • Introduces strcusx-exchange-rate endpoint + transport that derives PDAs, fetches/decodes Solana accounts, computes tranche rates, and applies required bounds.
  • Adds unit + integration tests covering PDA derivation, vesting behavior, bounds clamping, and validation/error cases.
  • Updates adapter wiring, README docs, and adds a changeset for release.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/sources/solana-functions/src/transport/strcusx-exchange-rate.ts Implements PDA derivation, account decoding, vesting adjustment, rate computation, and bounds clamping.
packages/sources/solana-functions/src/endpoint/strcusx-exchange-rate.ts Defines request params and response typing for the new endpoint.
packages/sources/solana-functions/src/endpoint/index.ts Exports the new endpoint.
packages/sources/solana-functions/src/index.ts Registers the new endpoint with the adapter.
packages/sources/solana-functions/test/unit/strcusx-exchange-rate.test.ts Unit tests for derivation, calculations, clamping, and error paths.
packages/sources/solana-functions/test/integration/strcusx-exchange-rate.test.ts Integration test validating end-to-end adapter response shape with mocked RPC.
packages/sources/solana-functions/README.md Documents the new endpoint and adds it to the endpoint list.
.changeset/fresh-tigers-pay.md Declares a minor bump for the adapter package due to the new endpoint.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +514 to +530
return {
data: {
result,
computedResult,
tranche,
decimals: RESULT_DECIMALS,
minRate: minRate.toString(),
maxRate: maxRate.toString(),
boundsApplied,
vestedTotalAssets: bookValueAssets.totalAssets.toString(),
vestedSeniorAssets: bookValueAssets.seniorAssets.toString(),
vestedJuniorAssets: juniorAssets.toString(),
seniorShares: accounting.seniorShares.toString(),
juniorShares: accounting.juniorShares.toString(),
unvestedTotalAssets: bookValueAssets.unvestedTotalAssets.toString(),
unvestedSeniorAssets: bookValueAssets.unvestedSeniorAssets.toString(),
},
@magiodev-cll magiodev-cll force-pushed the feat/strcusx-tranche-exchange-rate branch from 9cabe24 to 88fdc62 Compare June 24, 2026 16:21
@danwilliams-cll danwilliams-cll self-requested a review June 24, 2026 18:15
@danwilliams-cll

Copy link
Copy Markdown

Verdict: APPROVE — offset-to-layout migration is mechanically correct; layout spans match old magic constants exactly, no logic changes.

strcusx-exchange-rate.ts:L73: yagni: ControllerStateLayoutFields/StrategyStateLayoutFields/AccountingStateLayoutFields used only as BufferLayout.struct generic params, never referenced directly. Drop the generic type params; cast decoded result inline where needed. ~40 lines deleted.

strcusx-exchange-rate.ts:L224-241: shrink: five derive*Address exports each repeat .then((pda) => pda.toString()). A one-line local const toAddr = (p) => p.then(a => a.toString()) removes 5 identical chains. −5 lines.

"tranche": "senior",
"minRate": "950000000000000000",
"maxRate": "1050000000000000000"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

consider:

  1. The ability to enable/disable rateBound. For ie: applyBounds(boolean)
  2. The ability to switch between Solana Mainnet or Devnet. For ie: network(mainnet or devnet)

@mohamed-mehany

mohamed-mehany commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

@magiodev-cll
Please commit the program IDL and decode against it, the manual byte-offset decoding is the main risk left in this PR.

The decoders are hand-maintained constants (ACCOUNTING_TOTAL_ASSETS_OFFSET = 73, readU128LE(data, 73), hardcoded discriminators).

It's important because:

  1. No source of truth. Nothing proves 73 is actually where total_assets lives, it's a number read off the struct by hand. If it's off, you don't get an error, you get a plausible but wrong rate, which for a price feed is the worst failure mode (silent).

  2. The tests can't catch a wrong offset. The unit tests write fields at 41/57/73/... and assert the decoder reads them back from 41/57/73/..., circular. It proves encoder/decoder agree with each other, not that they match the on-chain program. A transposed field passes CI green; only the manual devnet smoke test would catch it.

  3. Silent breakage on upgrade / alignment. A program redeploy that reorders or pads a field keeps returning 200s computed from the wrong bytes (the discriminator is keyed to the account name, not its layout). These look like zero-copy/bytemuck structs, where C alignment/padding is exactly where hand-counted offsets go wrong.

What I'd like to see:

  • Commit the IDL to src/idl/ (repo convention, see eusx_yield_vault.json).
  • Decode through it: SolanaAccountReader (BorshCoder) like eusx-price for Borsh accounts, or a named @solana/buffer-layout struct (like the Sanctum layout in buffer-layout-accounts.ts) if these are zero-copy.
  • Add a test that decodes a recorded real on-chain account fixture (there are already fixtures under test/fixtures/) and checks the fields, that's the test that actually validates the layout, which the current self-consistent tests can't.

Also:

  • Consider classifying genuine upstream/RPC failures as AdapterDataProviderError rather than
    AdapterInputError (500), better error metrics; only bad request input should be 4xx
    AdapterInputError.

@mohamed-mehany

mohamed-mehany commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

@magiodev-cll
Re: the custom IDL ->buffer-layout decoderL I tested it and we can delete it in favor of the standard Anchor coder.

I ran @coral-xyz/anchor's BorshAccountsCoder against the real on-chain fixture committed in this PR, and it decodes all three accounts correctly:

#0 (space=362) decode('Controller')      -> asset_mint=4ujhCk…  is_paused=0
#1 (space=617) decode('Strategy')        -> name=STRC-USX-1     is_paused=0
#2 (space=441) decode('AccountingState') -> total_assets=650000001  senior_assets=200000000
                                            senior_shares=200000000  junior_shares=450000000
                                            total_vesting_assets=1  vesting_start=1780477916  end=1780506716

These are repr(C, packed) bytemuck accounts, and packed strips alignment padding, which makes the C layout byte-identical to Borsh for fixed-width primitives (u8/u32/u64/u128/pubkey/[u8;N]). bytemuck Pod types can't contain Vec/Option/enums, so this holds for every account of this program, not just these three. The BorshAccountsCoder constructor accepted the IDL and discriminators matched automatically.

This is exactly the pattern the EA already uses in anchor-data:

Adopting it lets us remove the entire hand-built layer (IdlField/StrcusxYieldStrategyIdl types, getIdlType, getIdlFieldLayout, buildIdlAccountLayout, getIdlAccountDiscriminator, the *LayoutFields types, and the readU128LE/readU64LE/decodeAddress helpers — ~150 lines). The decode collapses to:

const coder = new BorshAccountsCoder(strcusxYieldStrategyIdl as unknown as Idl)
const accounting = coder.decode('AccountingState', accountingBuf)
const totalAssets = BigInt(accounting.total_assets.toString())   // anchor returns BN for u64/u128
const name = Buffer.from(accounting.name).toString('utf8').replace(/\0+$/, '')  // [u8;32] -> string

Only the BN->bigint and [u8;32]->string conversions remain, no offsets, no discriminator constants, no layout builder. Suggest also routing it through a programToIdlMap like anchor-data does, and keeping the fixture decode test as the regression guard. This is the change that makes the transport meaningfully shorter and aligns it with the existing EA convention.

@mohamed-mehany

mohamed-mehany commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

On a separate point, the response Data is a bit over-built. It currently returns 14 fields:

result, computedResult, tranche, decimals, minRate, maxRate, boundsApplied, vestedTotalAssets, vestedSeniorAssets, vestedJuniorAssets, seniorShares, juniorShares, unvestedTotalAssets, unvestedSeniorAssets

Only result is consumed by the feed/aggregator, everything else in Data is off-chain observability (cached/logged, never goes on-chain), so this is cheap and not a correctness issue. But for comparison, the closest existing endpoint, pool-token-rate, returns just the rate + decimals + the two raw inputs it divided:

return {
data: {
rate: result,
decimals: RESULT_DECIMALS,
totalLamports: totalLamports.toString(),
poolTokenSupply: poolTokenSupply.toString(),
},
statusCode: 200,

By that bar I'd suggest:

  • Keep: result, computedResult (pre-clamp value), boundsApplied (so a clamp is observable), decimals, tranche, and the numerator + denominator actually used for the requested tranche (the vested assets + shares), that mirrors pool-token-rate exposing its inputs for auditability.
  • Trim: echoing minRate/maxRate back (the caller supplied them), and returning both tranches' internals when a request only prices one (a junior request doesn't need seniorShares; a senior request doesn't need vestedJuniorAssets/juniorShares). Returning the full junior+senior+vested+unvested set on every call is the over-built part.

That roughly halves the field set, tightens the response contract, and reduces snapshot churn.

Please let me know if there's a plan to use some of those fields though.

@mohamed-mehany

Copy link
Copy Markdown
Collaborator

On the validation in the transport, I think a lot of the assert* calls aren't actually needed, and they're a big part of why this reads as heavy. Breaking them down:

Keep (genuine safety):

  • assertDataLength / assertDiscriminator: decode safety. (Note: if we move to BorshAccountsCoder per the other comment, both come for free and can be deleted.)
  • The bigint underflow guards in calculateBookValueAssets and totalAssets < seniorAssets (vested): bigint has no underflow trap, so these stop a negative intermediate from producing a garbage rate.
  • The zero-shares check before dividing.
  • assertProgramOwner / assertTokenProgramOwner: these confirm a derived PDA actually exists and is the right account type (an uninitialized PDA is owned by System, not the program). Worth keeping, though it's really one concern repeated 6x.

Drop (re-checking what's already guaranteed):

  • assertNameMatches(strategy.name / accounting.name, strategyName): those PDAs are derived from strategyName as a seed, so the name is that seed by construction; can't fail on a correct program.
  • assertAddressMatches(strategy.juniorMintAddress / seniorMintAddress, expected...): comparing a derived PDA to the strategy's stored copy of the same thing; equal by construction.
  • assertAssetMintDecimals === 6: the rate now uses the decoded assetMint.decimals, so this doesn't affect correctness; it's a tripwire for a non-issue.
  • juniorMint.supply !== accounting.juniorShares / senior: a 1:1 program invariant, read atomically via getMultipleAccounts (same slot), so it can't be transiently inconsistent; only fires on a program/decode bug.

The reasoning: programAddress is operator-supplied, so the realistic failure modes are (a) wrong program/strategy configured, (b) a decode bug, or (c) transient RPC. None of the invariant asserts defend against (a), since a wrong-but-valid (or cloned) program satisfies all of them. What actually protects against (a) is the operator config plus the minRate/maxRate clamp, which is the real backstop; (b) is best handled by BorshAccountsCoder plus the fixture decode test. So the program-invariant asserts mostly defend against "the program wrote impossible state," which a correct program never does: extra code, error paths, and tests without covering a real threat.

One inconsistency the other way: we decode controller.isPaused, strategy.isPaused, and strategy.status but never check them. If "paused" means the rate is stale/untrustworthy, that's arguably the one state check worth keeping; if it doesn't matter, let's not decode those fields at all. Right now it's dead decoded data.

Net: keep the decode-safety plus underflow/div-by-zero plus owner/existence checks, drop the name/address/decimals/supply re-validations, and decide whether isPaused matters. Fewer error paths and tests, much more readable, with bounds remaining the actual safety net.

@mohamed-mehany

Copy link
Copy Markdown
Collaborator

Following on from the validation point, the equality/!== data-checks in _handleRequest are mostly redundant and can go. Going through them:

Name matches (assertNameMatches(strategy.name / accounting.name, strategyName)): these accounts are fetched at PDAs derived from strategyName as a seed, and we already assertProgramOwner on them. PDA derivation binds the seed to the address, so a program-owned account at that PDA is the one for that name. A non-existent strategy fails the owner check first, so the name check is unreachable for bad input and tautological for good input. Pure program-bug tripwire.

Supply equals shares (juniorMint.supply !== accounting.juniorShares / senior): this is a program invariant, and the accounts are read in the same atomic getMultipleAccounts slot, so there's no skew to catch. More importantly, the rate uses accounting.juniorShares/seniorShares, not mint.supply, so even if they disagreed the computed rate wouldn't change. The check protects nothing the rate depends on.

Decimals equals 6 (assertAssetMintDecimals): the rate now uses the decoded assetMint.decimals, so the math is correct for any precision. This !== 6 check would reject a perfectly computable rate if USX were ever redeployed at different decimals. Tripwire for a non-issue.

Address matches (assertAddressMatches(strategy.junior/seniorMintAddress, expected…)): these compare our independently-derived mint PDAs to the addresses the strategy account already stores. For a correct program they're equal by construction; the only thing they validate is that our JUNIOR_MINT/SENIOR_MINT seed assumption is right, which the fixture/integration test already covers.

One simplification these point at: the address-match checks only exist because we front-derive the mints to batch them into the first atomic read. But the mints are only used for (a) decimals (static, atomicity irrelevant) and (b) the supply == shares check. Once supply == shares is dropped, the mints don't need to be in the atomic batch, so we can read them using the strategy's stored juniorMintAddress/seniorMintAddress (the program's own source of truth) in a follow-up call, which removes the front-derivation and the address-match cross-check entirely.

Keep: the selectedComputedRate === null div-by-zero guard, the bigint underflow guard before juniorAssets = bookValue.total - bookValue.senior (the raw totalAssets < seniorAssets check is largely redundant with that and can probably go too), and the min/max bounds clamp, which is the actual safety net against a bad number.

Net: these !== checks only fire on a program or decode bug, they're an arbitrary subset that wouldn't comprehensively catch a mis-decode anyway, and they add error paths and test cases for false confidence. The fixture decode test covers decode correctness and the bounds cover a bad rate, so I'd drop the name/supply/decimals/address checks.

Comment on lines +45 to +57
const providerError = (message: string) =>
new AdapterDataProviderError(
{
message,
statusCode: 502,
},
{
providerDataRequestedUnixMs: 0,
providerDataReceivedUnixMs: 0,
providerIndicatedTimeUnixMs: undefined,
},
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@magiodev-cll magiodev-cll force-pushed the feat/strcusx-tranche-exchange-rate branch from 897d94a to 5adee8f Compare June 25, 2026 16:50
@magiodev-cll magiodev-cll force-pushed the feat/strcusx-tranche-exchange-rate branch from d9e1aec to eb5c92e Compare June 26, 2026 10:19
const decodeAnchorAccount = <T>(accountName: string, data: Buffer) =>
strcusxAccountsCoder.decode(accountName, data) as T

const assertProgramOwner = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

assertProgramOwner is a thin wrapper over the shared assertOwnerProgram (fixed message + single-element array). Either inline the three call sites or keep it, minor, but it's one more function for little gain.

Comment on lines +48 to +55
type DecodedControllerState = {
asset_mint: Stringable
}

type DecodedStrategyState = {
junior_mint: Stringable
senior_mint: Stringable
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The trivial Decoded*State types: DecodedControllerState (1 field) and DecodedStrategyState (2 fields), can be inlined into their decode functions rather than declared as named types. DecodedAccountingState (8 fields, line 57) is the one worth keeping for field-name safety, since it mirrors the bigint domain type.

return derivePda(programAddress, [PDA_SEEDS.ACCOUNTING, strategyName]).then((pda) =>
pda.toString(),
)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Collapse the redundant helpers

  • The three derive*Address functions (lines 108, 112, 116) are near-identical, they differ only by seed. Collapse to one:
const deriveAccountAddress = (programAddress: string, seeds: PdaSeed[]) =>
 derivePda(programAddress, seeds).then((pda) => pda.toString())
  • and call deriveAccountAddress(programAddress, [PDA_SEEDS.STRATEGY, strategyName]) etc. Removes two functions. (They're currently exported only for tests, the test can call the one helper with different seeds.)

type RequestParams = typeof inputParameters.validated
type Tranche = 'junior' | 'senior'

type Stringable = { toString(): string }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • Stringable (line 46), toBigint (line 91), and decodeAnchorAccount (line 93) are generic Anchor-decoding utilities. decodeAnchorAccount just needs to take the coder as a param to be reusable:
// shared/anchor-utils.ts  (or fold into account-reader.ts, which already wraps BorshCoder)
export type Stringable = { toString(): string }
export const toBigint = (v: Stringable) => BigInt(v.toString())
export const decodeAnchorAccount = <T>(coder: BorshAccountsCoder, name: string, data: Buffer) => coder.decode(name, data) as T
  • Note account-reader.ts already constructs a BorshCoder and decodes, so there's a real case to consolidate the "decode an Anchor account" concern there instead of having two BorshCoder code paths in the EA.

Comment on lines +35 to +40
const PDA_SEEDS = {
CONTROLLER: 'CONTROLLER',
STRATEGY: 'STRATEGY',
ACCOUNTING: 'ACCOUNTING',
} as const

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Everything domain-specific PDA_SEEDS, strcusxAccountsCoder, parseStrategyName, the Decoded*/AccountingState types, decodeControllerState/decodeStrategyState/decodeAccountingState, the collapsed deriveAccountAddress, and calculateBookValueAssets moves to a co-located file, e.g. src/transport/strcusx-accounts.ts (or strcusx-strategy.ts). The transport then imports from it and contains only the class (initialize/backgroundHandler/handleRequest/_handleRequest/getSubscriptionTtl).

This is consistent with the EA's existing pattern of putting decode logic in dedicated modules (account-reader.ts, buffer-layout-accounts.ts), just co-located since it's program-specific rather than shared.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. Please Add a test that mocks getMultipleAccounts().send() rejecting and asserts handleRequest produces the 502 error response. The catch/responseCache.write path is untested.

  2. Please add a Token-2022-with-extensions decode test for the asset/tranche mints, since decodeMintInfo tolerates trailing extension bytes in prod but the tests only feed base-span buffers.

@magiodev-cll magiodev-cll force-pushed the feat/strcusx-tranche-exchange-rate branch from eb5c92e to a60669c Compare June 26, 2026 13:57
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.

5 participants