Skip to content

Conversation

paul-chain
Copy link
Contributor

@paul-chain paul-chain commented Sep 26, 2025

Description

Adds a new Coinpaprika State external adapter that streams state_price via Server-Sent Events (SSE).
See package README for full details

Changes

  • New package: @chainlink/coinpaprika-state-adapter
  • Endpoint: coinpaprika-state (alias: state)
  • Transport: single-connection SSE with:
    • pair batching & dynamic reconnect on pair-set change
    • JSON parsing with malformed-event tolerance
    • graceful shutdown & reconnection backoff with jitter

Sample Input and Output

Sample Input

{
  "data": {
    "base": "LUSD",
    "quote": "USD",
    "endpoint": "coinpaprika-state"
  }
}

Using the alias state

{
  "data": {
    "base": "LUSD", 
    "quote": "USD",
    "endpoint": "state"
  }
}

Without endpoint (uses default)

{
  "data": {
    "base": "LUSD",
    "quote": "USD"
  }
}

Sample Output

{
  "data": {
    "result": 1.000979,
    "timestamp": 1758888503
  },
  "statusCode": 200,
  "result": 1.000979,
  "timestamps": {
    "providerDataRequestedUnixMs": 1758888508939,
    "providerDataReceivedUnixMs": 1758888508939,
    "providerIndicatedTimeUnixMs": 1758888503000
  },
  "meta": {
    "adapterName": "COINPAPRIKA_STATE",
    "metrics": {
      "feedId": "{\"base\":\"lusd\",\"quote\":\"usd\"}"
    }
  }
}

Example cURL Requests

Using the primary endpoint coinpaprika-state

curl -X POST http://localhost:8080 \
  -H "Content-Type: application/json" \
  -d '{"data":{"base":"LUSD","quote":"USD","endpoint":"coinpaprika-state"}}'

Using the alias state

curl -X POST http://localhost:8080 \
    -H "Content-Type: application/json" \
    -d '{"data": {"base": "LUSD", "quote": "USD", "endpoint": "state"}}'

Without endpoint (uses default)

curl -X POST http://localhost:8080 \
  -H "Content-Type: application/json" \
  -d '{"data": {"base": "LUSD", "quote": "USD"}}'

Steps to Test

  1. Navigate to the root of the external-adapters-js repo
  2. Build the adapter: yarn workspace @chainlink/coinpaprika-state-adapter build
  3. Run tests:
  • Integration test: yarn test packages/sources/coinpaprika-state/test/integration/adapter.test.ts
  • Unit test: yarn test packages/sources/coinpaprika-state/test/unit/sse.test.ts

Quality Assurance

  • If a new adapter was made, or an existing one was modified so that its environment variables have changed, update the relevant infra-k8s configuration file.
  • If a new adapter was made, or an existing one was modified so that its environment variables have changed, update the relevant adapter-secrets configuration file or update the soak testing blacklist.
  • If a new adapter was made, or a new endpoint was added, update the test-payload.json file with relevant requests.
  • The branch naming follows git flow (feature/x, chore/x, release/x, hotfix/x, fix/x) or is created from Jira.
  • This is related to a maximum of one Jira story or GitHub issue.
  • Types are safe (avoid TypeScript/TSLint features like any and disable, instead use more specific types).
  • All code changes have 100% unit and integration test coverage. If testing is not applicable or too difficult to justify doing, the reasoning should be documented explicitly in the PR.

Copy link

changeset-bot bot commented Sep 26, 2025

🦋 Changeset detected

Latest commit: e37a657

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

This PR includes changesets to release 1 package
Name Type
@chainlink/coinpaprika-state-adapter Major

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

@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch from 9e8c109 to 76c20cb Compare September 26, 2025 18:46
@karen-stepanyan
Copy link
Contributor

any reason why this is not a new endpoint on existing coinpaprika EA ?

@paul-chain paul-chain changed the title Add Coinpaprika State adapter OPDATA-4082 Add Coinpaprika State adapter Sep 29, 2025
@paul-chain paul-chain changed the title OPDATA-4082 Add Coinpaprika State adapter [OPDATA-4082] Add Coinpaprika State adapter Sep 29, 2025
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch 3 times, most recently from 1156b6b to 14c598a Compare September 30, 2025 17:49
@paul-chain
Copy link
Contributor Author

Updated to use existing EA with new endpoint

@alejoberardino
Copy link
Collaborator

saw this pr randomly, there's an sse transport in the framework, what's the particular reason this adapter needs a custom one?

@paul-chain
Copy link
Contributor Author

@alejoberardino I investigated SseTransport but found it's designed around the usual EventSource/GET model (and optional separate sub/unsub/keepalive calls). The current design of Coinpaprika State API instead opens a single SSE connection via POST with a JSON body containing all pairs and expects reconnects when the pair set changes. Given that shape, SubscriptionTransport is a cleaner fit for batching pairs and managing one shared connection + reconnection logic.

@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch 2 times, most recently from 7a62e70 to 2842eb9 Compare October 6, 2025 16:39
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch 4 times, most recently from 68944e9 to e535a01 Compare October 14, 2025 18:56
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch from 9b18db7 to c396c39 Compare October 14, 2025 19:27
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch from de77ca1 to 0f9ba62 Compare October 15, 2025 17:06
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch 2 times, most recently from 7ac26d5 to 0857f7e Compare October 16, 2025 20:08
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch from 0857f7e to f8da8ab Compare October 16, 2025 22:03
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch from f8da8ab to 944e7d9 Compare October 17, 2025 15:21
await this.responseCache.write(this.name, [{ params: param, response }])
}

async _handleRequest(
Copy link
Contributor

Choose a reason for hiding this comment

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

can this ever be called?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes - fallback when no cache yet. Returns 504.

* Single-connection SSE transport that batches all pairs into one POST and streams state_price ticks into the cache.
*
* */
export class CoinpaprikaStateTransport extends SubscriptionTransport<TransportTypes> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Just wondering, did you try extending StreamingTransport and implementing streamHandler?
At a glance it seems like that would manage subscriptions for you rather than rewriting havePairsChanged.

BTW: Coinmetrics is a different WS-based EA that switches URLs for every new subscription, not sure if there is any inspiration to pull from it: https://github.com/smartcontractkit/external-adapters-js/blob/main/packages/sources/coinmetrics/src/transport/lwba.ts#L62-L75

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point. Would be cleaner. Added TODO.

return adapterSettings.WARMUP_SUBSCRIPTION_TTL
}

async close(): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

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

is this used?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes - app shutdown, Lambda cleanup.

* - Supports multi-line "data:" blocks
* - Ignores comment/heartbeat lines starting with ':'
*/
export class SSEParser {
Copy link
Contributor

Choose a reason for hiding this comment

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

is there no library that can do some of this for us?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, eventsource exists. Current impl works fine but added TODO.

providerIndicatedTimeUnixMs: blockTime * 1000,
},
}
logger.debug(`tick ${param.base}/${param.quote}=${statePrice} t=${blockTime}`)
Copy link
Contributor

Choose a reason for hiding this comment

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

are 2 logs needed or is 1 debug enough?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed - 1 debug only.

}))

logger.debug(`Opening single SSE connection for ${pairsArray.length} pairs`)
logger.debug(`Pairs: ${pairsArray.map((p) => `${p.base}/${p.quote}`).join(', ')}`)
Copy link
Contributor

Choose a reason for hiding this comment

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

isn't this a duplicate log in backgroundHandler?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, consolidated.

const { response } = await this.requester.request<Readable>(key, req)
const httpResp = response

if (httpResp.status !== 200 || !httpResp.data) {
Copy link
Contributor

@mmcallister-cll mmcallister-cll Oct 18, 2025

Choose a reason for hiding this comment

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

maybe extract this section into an sseConnectionErrorHandler function for clarity since this is already a long function

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done - extracted handleSSEConnectionError().

quote: 'USD',
})

expect([504, 502]).toContain(response.statusCode)
Copy link
Contributor

Choose a reason for hiding this comment

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

504 or 502?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed - specific codes now.

scope.done()
})

it('pair-set change triggers reconnect immediately (first stream kept open)', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

is the (first stream kept open) part correct here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope, we close it. Fixed title.

logger.debug(`Pairs: ${pairsArray.map((p) => `${p.base}/${p.quote}`).join(', ')}`)

this.lastConnectionAttempt = Date.now()
this.currentAbortController = new AbortController()
Copy link
Contributor

Choose a reason for hiding this comment

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

could investigate whether it's worth including this in a superclass over the requester (something like AbortableRequester). Might not be worth the effort but could save some of this complexity for setting/unsetting the AbortController

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea but I think for now not priority. will put todo

const delay = Math.max(this.reconnectDelay - since, 0) + Math.floor(Math.random() * 1000)
logger.info(`SSE ended; reconnecting in ${delay} ms...`)
await sleep(delay)
await this.createSSEConnection(context)
Copy link
Contributor

@mmcallister-cll mmcallister-cll Oct 18, 2025

Choose a reason for hiding this comment

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

should we be doing this here or just let the next backgroundHandler invocation start a new connection?

In fact, could also extract the management of timing these connections into some SSEConnectionManager class or similar

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right. I make let backgroundHandler handle it.

@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch from f2f6755 to f36633c Compare October 19, 2025 18:27
- Extract error handler from long function for better readability
- Remove duplicate logging between background and connection handlers
- Fix test status codes and incorrect test descriptions
- Add TODOs for potential future improvements
- Simplify reconnection logic to use background cycles
- Move utility function to separate testable module
@paul-chain paul-chain force-pushed the feat/add-coinpaprika-state-adapter branch from f36633c to e37a657 Compare October 19, 2025 18:39
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