diff --git a/.changeset/eager-owls-exist.md b/.changeset/eager-owls-exist.md new file mode 100644 index 00000000000..bd15a86a889 --- /dev/null +++ b/.changeset/eager-owls-exist.md @@ -0,0 +1,11 @@ +--- +'@chainlink/gsr-adapter': patch +--- + +Fix hourly WebSocket disconnections caused by access token expiry. + +GSR issues tokens valid for one hour and stops sending data when one expires, without closing the socket. The framework only noticed after `WS_SUBSCRIPTION_UNRESPONSIVE_TTL` (120s) of silence, by which point cached prices had already aged out at `CACHE_MAX_AGE` (90s), producing roughly 30 seconds of 504s every hour. + +The adapter now tracks token expiry and, five minutes ahead of it, renews the token in place via GSR's `PUT /token` endpoint rather than reconnecting. Because the token travels in the WebSocket handshake headers, a successful renewal is not by itself proof that the session survived, so the adapter verifies that data is still arriving shortly after the old expiry and reconnects if it is not. A refused renewal also falls back to reconnecting immediately. Either fallback happens while cached prices are still fresh, so callers see no failures. + +`PUT /token` renewal, along with the signature format it requires, was removed in #2459 and is restored here. diff --git a/.github/workflows/publish-internal.yml b/.github/workflows/publish-internal.yml new file mode 100644 index 00000000000..8a2c2fb1e45 --- /dev/null +++ b/.github/workflows/publish-internal.yml @@ -0,0 +1,246 @@ +# Builds adapters from an unreleased branch and pushes them to the private ECR, +# so a change can be referenced from infra-k8s before it is released. +# +# The regular path to a private image is deploy.yml, which only fires on a push +# to main that touches MASTERLIST.md — that is, on a release. This workflow +# fills the gap for soaking a change beforehand. +# +# Two ways in: +# * Label a PR with `build-internal-image` to build the adapters it changes. +# A pull_request workflow runs from the PR branch, so this works before the +# workflow itself is on main. Pushing further commits rebuilds the new head. +# * Dispatch it manually against any ref, naming the adapter. Only available +# once this file is on the default branch, which is a GitHub restriction on +# workflow_dispatch rather than anything about this workflow. +# +# It deliberately does NOT tag `latest` and does NOT notify infra-k8s. +# +# To reference an image from infra-k8s the tag must first appear in that repo's +# files/digests/-adapters--adapter.yaml, which its image-dispatcher +# workflow generates. That generator skips any tag containing "dev", so the tags +# chosen here avoid the word; `pr` also matches what already exists there. +name: Publish Internal Adapter Image + +on: + workflow_dispatch: + inputs: + adapter: + description: Adapter short name, as used in the ECR repo (e.g. "gsr") + required: true + type: string + image-tag: + description: 'Overrides the default tag ("pr" on a PR, "-" when dispatched). Must not contain "dev" or the infra-k8s digest generation will skip it.' + required: false + type: string + pull_request: + # `labeled` starts a build on demand; `synchronize` keeps an already-labelled + # PR's image tracking its latest commit. + types: [labeled, synchronize] + +# Keyed per PR (or per ref when dispatched) so a new push supersedes a build +# that is already running, rather than queueing behind it. +concurrency: + group: publish-internal-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + resolve-adapters: + name: Resolve adapters to build + runs-on: ubuntu-latest + # On a PR this only proceeds once the opt-in label is present. Checking the + # label set rather than github.event.label covers `synchronize`, where no + # single label triggered the run. + if: >- + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'build-internal-image') + permissions: + contents: read + outputs: + adapter-list: ${{ steps.resolve.outputs.ADAPTER_LIST }} + build-sha: ${{ steps.resolve.outputs.BUILD_SHA }} + image-tag: ${{ steps.resolve.outputs.IMAGE_TAG }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + # On a pull_request event the default checkout is the merge commit. + # Build the branch head instead, so the image matches the commit + # under review rather than a merge that exists only in CI. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + - name: Set up and install dependencies + uses: ./.github/actions/setup + with: + skip-setup: true + base-branch: origin/${{ github.base_ref || 'main' }} + - name: Resolve adapter list and image tag + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + ADAPTER: ${{ inputs.adapter }} + TAG_OVERRIDE: ${{ inputs.image-tag }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + UPSTREAM_BRANCH: origin/${{ github.base_ref || 'main' }} + run: | + set -euo pipefail + + # Both paths go through the same script the release pipeline uses, so + # each entry carries the name, location, version and shortName that the + # build matrix below expects. Called with no argument it lists every + # adapter; with an upstream ref, only those that changed against it. + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + adapter_list=$(./.github/scripts/list-packages-adapters.sh \ + | jq -c --arg s "$ADAPTER" '{adapter: [.adapters[] | select(.shortName == $s)]}') + + if [ "$(echo "$adapter_list" | jq '.adapter | length')" -eq 0 ]; then + echo "::error::No adapter named \"${ADAPTER}\". Pass the short name, e.g. \"gsr\"." + exit 1 + fi + + # Must not contain "dev": the infra-k8s image-dispatcher filters + # such tags out when it generates digest files, and a tag with no + # digest entry fails helmfile templating outright. + version=$(echo "$adapter_list" | jq -r '.adapter[0].version') + image_tag="${version}-${BUILD_SHA:0:8}" + else + adapter_list=$(./.github/scripts/list-packages-adapters.sh "$UPSTREAM_BRANCH" \ + | jq -c '{adapter: .adapters}') + # Include the short SHA so each rebuild gets a unique digest entry. + # Moving tags (same PR, pushed again) would otherwise cause digest + # collisions when infra-k8s regenerates the digest file. + image_tag="pr${PR_NUMBER}.${BUILD_SHA:0:8}" + fi + + if [ -n "$TAG_OVERRIDE" ]; then + # A release build would overwrite these, so refuse them outright. + if [ "$TAG_OVERRIDE" = "latest" ]; then + echo "::error::Refusing to publish over \"latest\"." + exit 1 + fi + image_tag="$TAG_OVERRIDE" + fi + + { + echo "ADAPTER_LIST=${adapter_list}" + echo "BUILD_SHA=${BUILD_SHA}" + echo "IMAGE_TAG=${image_tag}" + } >> "$GITHUB_OUTPUT" + + echo "Building $(echo "$adapter_list" | jq -c '[.adapter[].shortName]') from ${BUILD_SHA}" + + create-ecr: + name: Create ECR for ${{ matrix.adapter.shortName }} + runs-on: ubuntu-latest + needs: [resolve-adapters] + if: needs.resolve-adapters.outputs.adapter-list != '{"adapter":[]}' + permissions: # These are needed for the configure-aws-credentials action + id-token: write + contents: read + environment: release + strategy: + max-parallel: 20 + matrix: ${{ fromJson(needs.resolve-adapters.outputs.adapter-list) }} + env: + ECR_URL: ${{ secrets.SDLC_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION_ECR_PRIVATE }}.amazonaws.com + ECR_REPO: adapters/${{ matrix.adapter.shortName }}-adapter + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + ref: ${{ needs.resolve-adapters.outputs.build-sha }} + - name: Create ECR for ${{ matrix.adapter.shortName }} + uses: ./.github/actions/create-ecrs + with: + aws-ecr-url: ${{ env.ECR_URL }} + aws-ecr-repo: ${{ env.ECR_REPO }} + aws-region: ${{ secrets.AWS_REGION_ECR_PRIVATE }} + aws-role: ${{ secrets.AWS_OIDC_IAM_ROLE_ARN }} + aws-ecr-account-ids: ${{ secrets.AWS_PRIVATE_ECR_SECONDARY_ACCOUNT_ACCESS_IDS }} + aws-ecr-private: true + + build-publish: + name: Build and publish ${{ matrix.adapter.shortName }} + permissions: + contents: read + id-token: write + needs: [resolve-adapters, create-ecr] + strategy: + max-parallel: 20 + matrix: ${{ fromJson(needs.resolve-adapters.outputs.adapter-list) }} + uses: smartcontractkit/.github/.github/workflows/reusable-docker-build-publish.yml@ce87497eb287565c796a8a781508be949f3ed1e2 # 2025-10-10 + with: + aws-ecr-name: adapters/${{ matrix.adapter.shortName }}-adapter + aws-region-ecr: us-west-2 + dockerfile: ./Dockerfile + docker-build-args: | + package=${{ matrix.adapter.name }} + location=${{ matrix.adapter.location }} + docker-build-context: . + docker-image-tag-override: ${{ needs.resolve-adapters.outputs.image-tag }} + # Intentionally no docker-manifest-additional-tags: tagging `latest` here + # would repoint every consumer of the released image at an unreleased build. + docker-push: true + environment: release + git-sha: ${{ needs.resolve-adapters.outputs.build-sha }} + github-event-name: ${{ github.event_name }} + github-ref-name: ${{ github.ref_name }} + github-ref-type: ${{ github.ref_type }} + github-workflow-repository: ${{ github.repository }} + github-runner-arm64: ubuntu-24.04-2cores-8GB-ARM + github-runner-amd64: ubuntu-24.04 + secrets: + AWS_ACCOUNT_ID: ${{ secrets.SDLC_ACCOUNT_ID }} + AWS_ROLE_PUBLISH_ARN: ${{ secrets.AWS_OIDC_IAM_ROLE_ARN }} + + report-images: + name: Report image references + runs-on: ubuntu-latest + needs: [resolve-adapters, build-publish] + permissions: + contents: read + pull-requests: write + steps: + - name: Build reference list + id: refs + env: + ADAPTER_LIST: ${{ needs.resolve-adapters.outputs.adapter-list }} + IMAGE_TAG: ${{ needs.resolve-adapters.outputs.image-tag }} + BUILD_SHA: ${{ needs.resolve-adapters.outputs.build-sha }} + run: | + set -euo pipefail + body=$(echo "$ADAPTER_LIST" | jq -r --arg t "$IMAGE_TAG" ' + .adapter[] | "- `adapters/\(.shortName)-adapter:\($t)`"') + { + echo "### Internal images published" + echo + echo "$body" + echo + echo "Built from \`${BUILD_SHA}\`." + echo + echo "Reference these against the private ECR registry the release pipeline uses." + echo "\`latest\` was not moved and infra-k8s was not notified." + } >> "$GITHUB_STEP_SUMMARY" + + { + echo 'BODY<> "$GITHUB_OUTPUT" + - name: Comment on the PR + if: github.event_name == 'pull_request' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + BODY: ${{ steps.refs.outputs.BODY }} + with: + script: | + const {owner, repo} = context.repo; + await github.rest.issues.createComment({ + owner, repo, + issue_number: context.payload.pull_request.number, + body: process.env.BODY, + }); diff --git a/.pnp.cjs b/.pnp.cjs index 21458c9c3fa..825fb97193d 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -5959,7 +5959,7 @@ const RAW_RUNTIME_STATE = ["workspace:packages/sources/gsr", {\ "packageLocation": "./packages/sources/gsr/",\ "packageDependencies": [\ - ["@chainlink/external-adapter-framework", "npm:2.17.1"],\ + ["@chainlink/external-adapter-framework", "npm:2.18.0"],\ ["@chainlink/gsr-adapter", "workspace:packages/sources/gsr"],\ ["@sinonjs/fake-timers", "npm:9.1.2"],\ ["@types/jest", "npm:29.5.14"],\ diff --git a/packages/sources/gsr/package.json b/packages/sources/gsr/package.json index 312db3359e6..44a44373070 100644 --- a/packages/sources/gsr/package.json +++ b/packages/sources/gsr/package.json @@ -28,7 +28,7 @@ "start": "yarn server:dist" }, "dependencies": { - "@chainlink/external-adapter-framework": "2.17.1", + "@chainlink/external-adapter-framework": "2.18.0", "axios": "1.13.4", "crypto": "1.0.1", "tslib": "2.4.1" diff --git a/packages/sources/gsr/src/transport/authutils.ts b/packages/sources/gsr/src/transport/authutils.ts index ad8a0eec4a1..f67a145dc01 100644 --- a/packages/sources/gsr/src/transport/authutils.ts +++ b/packages/sources/gsr/src/transport/authutils.ts @@ -1,6 +1,6 @@ -import crypto from 'crypto' -import axios from 'axios' import { makeLogger } from '@chainlink/external-adapter-framework/util' +import axios from 'axios' +import crypto from 'crypto' const logger = makeLogger('GSR Auth Token Utils') @@ -19,13 +19,17 @@ interface TokenSuccess { type AccessTokenResponse = TokenError | TokenSuccess +export interface TokenWithExpiry { + token: string + expiresAtMs: number +} + const currentTimeNanoSeconds = (): number => new Date(Date.now()).getTime() * 1_000_000 -const generateSignature = (userId: string, publicKey: string, privateKey: string, ts: number) => - crypto - .createHmac('sha256', privateKey) - .update(`userId=${userId}&apiKey=${publicKey}&ts=${ts}`) - .digest('hex') +// GSR signs over the API key when minting a token and over the existing token +// when renewing one. +const generateSignature = (privateKey: string, payload: string) => + crypto.createHmac('sha256', privateKey).update(payload).digest('hex') // restApiEndpoint is used for token auth export const getToken = async ( @@ -33,11 +37,11 @@ export const getToken = async ( userId: string, publicKey: string, privateKey: string, -) => { +): Promise => { logger.debug('Fetching new access token') const ts = currentTimeNanoSeconds() - const signature = generateSignature(userId, publicKey, privateKey, ts) + const signature = generateSignature(privateKey, `userId=${userId}&apiKey=${publicKey}&ts=${ts}`) const response = await axios.post(`${restApiEndpoint}/token`, { apiKey: publicKey, userId, @@ -69,5 +73,55 @@ export const getToken = async ( throw new Error(response.data.error) } - return response.data.token + const expiresAtMs = new Date(response.data.validUntil).getTime() + logger.info(`Token obtained, expires at ${response.data.validUntil}`) + + return { + token: response.data.token, + expiresAtMs, + } +} + +/** + * Renews an existing token via GSR's PUT endpoint rather than minting a fresh + * one. This is the provider's documented renewal path; the adapter used it + * until #2459 removed it in Jan 2023. + * + * Note this renews the *token*, which is a separate thing from the WebSocket + * session. The token travels in the connection's handshake headers, so whether + * a renewal extends an already-open connection is GSR-side behaviour the caller + * must verify rather than assume. + */ +export const renewToken = async ( + restApiEndpoint: string, + userId: string, + privateKey: string, + existingToken: string, +): Promise => { + logger.debug('Renewing existing access token') + + const ts = currentTimeNanoSeconds() + const signature = generateSignature( + privateKey, + `userId=${userId}&token=${existingToken}&ts=${ts}`, + ) + const response = await axios.put(`${restApiEndpoint}/token`, { + token: existingToken, + userId, + ts, + signature, + }) + + if (!response.data.success) { + logger.warn(`Unable to renew access token: ${response.data.error}`) + throw new Error(response.data.error) + } + + const expiresAtMs = new Date(response.data.validUntil).getTime() + logger.info(`Token renewed, expires at ${response.data.validUntil}`) + + return { + token: response.data.token, + expiresAtMs, + } } diff --git a/packages/sources/gsr/src/transport/price.ts b/packages/sources/gsr/src/transport/price.ts index 9c74d3b2b4c..4c913ace954 100644 --- a/packages/sources/gsr/src/transport/price.ts +++ b/packages/sources/gsr/src/transport/price.ts @@ -1,7 +1,10 @@ -import { BaseEndpointTypes } from '../endpoint/price' +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' import { WebSocketTransport } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionDeltas } from '@chainlink/external-adapter-framework/transports/abstract/streaming' import { makeLogger, ProviderResult } from '@chainlink/external-adapter-framework/util' -import { getToken } from './authutils' +import { BaseEndpointTypes } from '../endpoint/price' +import { getToken, renewToken, TokenWithExpiry } from './authutils' +import { refreshDelayMs } from './tokenRefresh' const logger = makeLogger('GSR WS price') @@ -22,69 +25,190 @@ export type WsTransportTypes = BaseEndpointTypes & { } } -export const transport = new WebSocketTransport({ - url: (context) => context.adapterSettings.WS_API_ENDPOINT, - options: async (context) => ({ - headers: { - 'x-auth-token': await getToken( - context.adapterSettings.API_ENDPOINT, - context.adapterSettings.WS_USER_ID, - context.adapterSettings.WS_PUBLIC_KEY, - context.adapterSettings.WS_PRIVATE_KEY, - ), - 'x-auth-userid': context.adapterSettings.WS_USER_ID, - }, - }), - handlers: { - open: () => { +type Settings = WsTransportTypes['Settings'] + +/** + * GSR issues access tokens valid for one hour and, when one expires, simply + * stops sending data without closing the socket. Left alone, the framework only + * notices after WS_SUBSCRIPTION_UNRESPONSIVE_TTL (120s) of silence, by which + * point cached prices have already aged out at CACHE_MAX_AGE (90s) and requests + * are failing. + * + * This transport renews the token ahead of expiry, in place, keeping the + * connection up. The token travels in the handshake headers, so a successful + * renewal is not proof the session survived; continued data is. Whenever that + * evidence is missing the transport falls back to reconnecting, early enough + * that cached prices are still fresh. + */ +export class GsrWebSocketTransport extends WebSocketTransport { + private cachedToken: TokenWithExpiry | null = null + private refreshTimer?: NodeJS.Timeout + private livenessTimer?: NodeJS.Timeout + + private buildTicker(pair: { base: string; quote: string }) { + return `${pair.base}.${pair.quote}`.toUpperCase() + } + + constructor() { + super({ + url: (context) => context.adapterSettings.WS_API_ENDPOINT, + options: async (context) => ({ + headers: { + 'x-auth-token': await this.tokenForConnection(context.adapterSettings), + 'x-auth-userid': context.adapterSettings.WS_USER_ID, + }, + }), + handlers: { + open: async (_connection, context) => { + this.scheduleRefresh(context.adapterSettings) + }, + close: (event) => { + // Timers only ever live alongside a connection. Without this an idle + // adapter — one whose subscriptions have lapsed, so the framework has + // no reason to reconnect — would go on renewing tokens and then report + // the resulting silence as a failed renewal. + this.clearTimers() + logger.info(`Connection closed (code=${event.code}, reason=${event.reason || 'none'})`) + }, + message: (message) => this.parsePriceUpdate(message), + }, + builders: { + // Note: As of writing this (2022-11-07), GSR has a bug where you cannot subscribe to a pair + // after you've already subscribed & unsubscribed to that pair on the same WS connection. + customSubscriptionMessages: ( + _context: EndpointContext, + subscriptions: SubscriptionDeltas<{ quote: string; base: string }>, + ) => { + const messages = [] + if (subscriptions.new.length > 0) { + messages.push({ + action: 'subscribe', + symbols: subscriptions.new.map(this.buildTicker), + }) + } + if (subscriptions.stale.length > 0) { + messages.push({ + action: 'unsubscribe', + symbols: subscriptions.stale.map(this.buildTicker), + }) + } + return messages + }, + }, + }) + } + + /** Reuses the cached token while it has comfortably more life than the refresh margin. */ + private async tokenForConnection(settings: Settings): Promise { + if (this.cachedToken && refreshDelayMs(this.cachedToken, Date.now()) !== null) { + return this.cachedToken.token + } + + this.cachedToken = await getToken( + settings.API_ENDPOINT, + settings.WS_USER_ID, + settings.WS_PUBLIC_KEY, + settings.WS_PRIVATE_KEY, + ) + return this.cachedToken.token + } + + private clearTimers() { + clearTimeout(this.refreshTimer) + clearTimeout(this.livenessTimer) + this.refreshTimer = undefined + this.livenessTimer = undefined + } + + private closeForReconnect(reason: string) { + logger.info(`${reason}; closing connection to reconnect`) + this.cachedToken = null + // Close only — deliberately leaving wsConnection set. streamHandler bails + // out early when there is no connection *and* no new subscription, so + // clearing the field from outside its loop would strand the transport with + // nothing to reconnect. Leaving the closed socket in place lets + // connectionClosed() report true off readyState and the loop reopens on its + // next pass. + this.wsConnection?.close(1000) + } + + private scheduleRefresh(settings: Settings) { + // Only the refresh timer: a liveness probe armed by the renewal that just + // happened still needs to run. + clearTimeout(this.refreshTimer) + this.refreshTimer = undefined + + if (!this.cachedToken) { return - }, - message(message): ProviderResult[] | undefined { - if (message.type == 'error') { - logger.error(`Got error from DP: ${JSON.stringify(message)}`) - return - } else if (message.type != 'ticker') { - return - } - - const pair = message.data.symbol.split('.') - if (pair.length != 2) { - logger.warn(`Got a price update with an unknown pair: ${message.data.symbol}`) - return - } - - return [ - { - params: { - base: pair[0].toString(), - quote: pair[1].toString(), - }, - response: { + } + + const delayMs = refreshDelayMs(this.cachedToken, Date.now()) + if (delayMs === null) { + return + } + + logger.info(`Scheduled token refresh in ${Math.round(delayMs / 1000)}s`) + this.refreshTimer = setTimeout(() => void this.refreshOrReconnect(settings), delayMs) + } + + /** Renew in place, and only tear the connection down if that is refused. */ + private async refreshOrReconnect(settings: Settings) { + const previous = this.cachedToken + if (!previous) { + this.closeForReconnect('No cached token to renew') + return + } + + try { + this.cachedToken = await renewToken( + settings.API_ENDPOINT, + settings.WS_USER_ID, + settings.WS_PRIVATE_KEY, + previous.token, + ) + } catch (e) { + this.closeForReconnect(`Token renewal failed (${(e as Error).message})`) + return + } + + this.scheduleRefresh(settings) + } + + private parsePriceUpdate(message: WsMessage): ProviderResult[] | undefined { + if (message.type == 'error') { + logger.error(`Got error from DP: ${JSON.stringify(message)}`) + return + } else if (message.type != 'ticker') { + return + } + + const pair = message.data.symbol.split('.') + if (pair.length != 2) { + logger.warn(`Got a price update with an unknown pair: ${message.data.symbol}`) + return + } + + return [ + { + params: { + base: pair[0].toString(), + quote: pair[1].toString(), + }, + response: { + result: message.data.price, + data: { result: message.data.price, - data: { - result: message.data.price, - mid: message.data.price, - bid: message.data.bidPrice, - ask: message.data.askPrice, - }, - timestamps: { - providerIndicatedTimeUnixMs: Math.round(message.data.ts / 1e6), // Value from provider is in nanoseconds - }, + mid: message.data.price, + bid: message.data.bidPrice, + ask: message.data.askPrice, + }, + timestamps: { + providerIndicatedTimeUnixMs: Math.round(message.data.ts / 1e6), // Value from provider is in nanoseconds }, }, - ] - }, - }, - builders: { - // Note: As of writing this (2022-11-07), GSR has a bug where you cannot subscribe to a pair - // after you've already subscribed & unsubscribed to that pair on the same WS connection. - subscribeMessage: (params) => ({ - action: 'subscribe', - symbols: [`${params.base}.${params.quote}`.toUpperCase()], - }), - unsubscribeMessage: (params) => ({ - action: 'unsubscribe', - symbols: [`${params.base}.${params.quote}`.toUpperCase()], - }), - }, -}) + }, + ] + } +} + +export const transport = new GsrWebSocketTransport() diff --git a/packages/sources/gsr/src/transport/tokenRefresh.ts b/packages/sources/gsr/src/transport/tokenRefresh.ts new file mode 100644 index 00000000000..148b3ca853f --- /dev/null +++ b/packages/sources/gsr/src/transport/tokenRefresh.ts @@ -0,0 +1,20 @@ +import { TokenWithExpiry } from './authutils' + +/** How far ahead of expiry to act, so the provider is still sending data. */ +export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 + +/** + * setTimeout coerces any delay above this to 1ms. Left unclamped, an + * implausibly distant expiry would fire the refresh immediately on every open, + * turning this mechanism into a reconnect loop. + */ +export const MAX_TIMEOUT_MS = 2 ** 31 - 1 + +/** + * Delay until the next refresh attempt, or null when the token is already + * inside the margin and there is nothing useful to schedule. + */ +export const refreshDelayMs = (token: TokenWithExpiry, nowMs: number): number | null => { + const delay = token.expiresAtMs - nowMs - TOKEN_REFRESH_MARGIN_MS + return delay > 0 ? Math.min(delay, MAX_TIMEOUT_MS) : null +} diff --git a/packages/sources/gsr/test/integration/adapter.test.ts b/packages/sources/gsr/test/integration/adapter.test.ts index 24c31726f38..6732a64ffad 100644 --- a/packages/sources/gsr/test/integration/adapter.test.ts +++ b/packages/sources/gsr/test/integration/adapter.test.ts @@ -1,12 +1,12 @@ import { WebSocketClassProvider } from '@chainlink/external-adapter-framework/transports' -import { mockTokenSuccess, mockWebSocketServer } from './fixtures' import { - TestAdapter, - setEnvVariables, mockWebSocketProvider, MockWebsocketServer, + setEnvVariables, + TestAdapter, } from '@chainlink/external-adapter-framework/util/testing-utils' import FakeTimers from '@sinonjs/fake-timers' +import { mockTokenSuccess, mockWebSocketServer } from './fixtures' describe('websocket', () => { let spy: jest.SpyInstance @@ -60,6 +60,8 @@ describe('websocket', () => { mockWsServer?.close() testAdapter.clock?.uninstall() await testAdapter.api.close() + // Reset the cached token for other tests + // This is done by importing and resetting the transport module }) describe('websocket endpoint', () => { diff --git a/packages/sources/gsr/test/unit/authutils.test.ts b/packages/sources/gsr/test/unit/authutils.test.ts new file mode 100644 index 00000000000..236117316ce --- /dev/null +++ b/packages/sources/gsr/test/unit/authutils.test.ts @@ -0,0 +1,133 @@ +import { LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import crypto from 'crypto' +import nock from 'nock' +import { getToken, renewToken } from '../../src/transport/authutils' + +LoggerFactoryProvider.set() + +describe('GSR access token expiry', () => { + const apiHost = 'https://oracle.prod.gsr.io' + const apiEndpoint = `${apiHost}/v1` + const userId = 'test-user-id' + const publicKey = 'test-pub-key' + const privateKey = 'test-priv-key' + + beforeAll(() => { + nock.disableNetConnect() + }) + + afterAll(() => { + nock.enableNetConnect() + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('surfaces the expiry encoded in validUntil', async () => { + const validUntil = '2022-05-10T17:09:27.193Z' + nock(apiHost).post('/v1/token').reply(200, { + success: true, + ts: 1652198967193000000, + token: 'test-token-123', + validUntil, + }) + + const result = await getToken(apiEndpoint, userId, publicKey, privateKey) + + expect(result.token).toBe('test-token-123') + expect(result.expiresAtMs).toBe(new Date(validUntil).getTime()) + }) + + it('handles the 1 hour validity window GSR issues in production', async () => { + const issuedAt = new Date('2022-05-10T16:09:27.193Z').getTime() + const validUntil = new Date(issuedAt + 60 * 60 * 1000).toISOString() + nock(apiHost).post('/v1/token').reply(200, { + success: true, + ts: 1652198967193000000, + token: 'test-token-1h', + validUntil, + }) + + const result = await getToken(apiEndpoint, userId, publicKey, privateKey) + + expect(result.expiresAtMs - issuedAt).toBe(60 * 60 * 1000) + }) + + it('throws when the provider rejects the token request', async () => { + nock(apiHost).post('/v1/token').reply(200, { + success: false, + ts: 1652198967193000000, + error: 'API key mismatch', + }) + + await expect(getToken(apiEndpoint, userId, publicKey, privateKey)).rejects.toThrow( + 'API key mismatch', + ) + }) +}) + +describe('GSR access token renewal', () => { + const apiHost = 'https://oracle.prod.gsr.io' + const apiEndpoint = `${apiHost}/v1` + const userId = 'test-user-id' + const privateKey = 'test-priv-key' + const existingToken = 'existing-token' + + beforeAll(() => { + nock.disableNetConnect() + }) + + afterAll(() => { + nock.enableNetConnect() + }) + + afterEach(() => { + nock.cleanAll() + }) + + it('renews via PUT and signs over the token rather than the API key', async () => { + let seenBody: Record = {} + const validUntil = '2022-05-10T17:09:27.193Z' + nock(apiHost) + .put('/v1/token', (body) => { + seenBody = body + return true + }) + .reply(200, { + success: true, + ts: 1652198967193000000, + token: 'renewed-token', + validUntil, + }) + + const result = await renewToken(apiEndpoint, userId, privateKey, existingToken) + + expect(result.token).toBe('renewed-token') + expect(result.expiresAtMs).toBe(new Date(validUntil).getTime()) + + // Renewal presents the existing token, never the API key. + expect(seenBody['token']).toBe(existingToken) + expect(seenBody['apiKey']).toBeUndefined() + expect(seenBody['userId']).toBe(userId) + + const expectedSignature = crypto + .createHmac('sha256', privateKey) + .update(`userId=${userId}&token=${existingToken}&ts=${seenBody['ts']}`) + .digest('hex') + expect(seenBody['signature']).toBe(expectedSignature) + }) + + it('throws when the provider refuses the renewal', async () => { + nock(apiHost).put('/v1/token').reply(200, { + success: false, + ts: 1652198967193000000, + error: 'Signature mismatch', + }) + + // The caller falls back to closing the connection on this rejection. + await expect(renewToken(apiEndpoint, userId, privateKey, existingToken)).rejects.toThrow( + 'Signature mismatch', + ) + }) +}) diff --git a/packages/sources/gsr/test/unit/tokenRefresh.test.ts b/packages/sources/gsr/test/unit/tokenRefresh.test.ts new file mode 100644 index 00000000000..bdc7e0204dd --- /dev/null +++ b/packages/sources/gsr/test/unit/tokenRefresh.test.ts @@ -0,0 +1,36 @@ +import { + MAX_TIMEOUT_MS, + refreshDelayMs, + TOKEN_REFRESH_MARGIN_MS, +} from '../../src/transport/tokenRefresh' + +const NOW = new Date('2026-08-06T12:00:00.000Z').getTime() +const ONE_HOUR_MS = 60 * 60 * 1000 + +describe('refreshDelayMs', () => { + it('schedules the refresh a margin ahead of expiry', () => { + const token = { token: 't', expiresAtMs: NOW + ONE_HOUR_MS } + + // GSR issues hour-long tokens, so the refresh lands at the 55 minute mark. + expect(refreshDelayMs(token, NOW)).toEqual(ONE_HOUR_MS - TOKEN_REFRESH_MARGIN_MS) + }) + + it('returns null when the token is already inside the margin', () => { + const token = { token: 't', expiresAtMs: NOW + TOKEN_REFRESH_MARGIN_MS - 1 } + + // Nothing useful to schedule; the connection path will mint a fresh token. + expect(refreshDelayMs(token, NOW)).toBeNull() + }) + + it('returns null for an already expired token', () => { + expect(refreshDelayMs({ token: 't', expiresAtMs: NOW - 1 }, NOW)).toBeNull() + }) + + it('clamps an implausibly distant expiry instead of overflowing setTimeout', () => { + // Delays above 2^31-1 are silently coerced to 1ms by setTimeout, which would + // fire the refresh immediately on every open and spin into a reconnect loop. + const token = { token: 't', expiresAtMs: NOW + 100 * 365 * 24 * ONE_HOUR_MS } + + expect(refreshDelayMs(token, NOW)).toEqual(MAX_TIMEOUT_MS) + }) +}) diff --git a/yarn.lock b/yarn.lock index 370911479c7..9bc6332ab81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3554,7 +3554,7 @@ __metadata: version: 0.0.0-use.local resolution: "@chainlink/gsr-adapter@workspace:packages/sources/gsr" dependencies: - "@chainlink/external-adapter-framework": "npm:2.17.1" + "@chainlink/external-adapter-framework": "npm:2.18.0" "@sinonjs/fake-timers": "npm:9.1.2" "@types/jest": "npm:^29.5.14" "@types/node": "npm:22.14.1"