diff --git a/.changeset/solid-chairs-study.md b/.changeset/solid-chairs-study.md new file mode 100644 index 0000000000..7c77b77029 --- /dev/null +++ b/.changeset/solid-chairs-study.md @@ -0,0 +1,5 @@ +--- +'@chainlink/tenbin-adapter': major +--- + +Initial version of Tenbin EA diff --git a/.pnp.cjs b/.pnp.cjs index d9952950ce..d9ec584878 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -594,6 +594,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/t-rize-proof-of-insurance-adapter",\ "reference": "workspace:packages/sources/t-rize-proof-of-insurance"\ },\ + {\ + "name": "@chainlink/tenbin-adapter",\ + "reference": "workspace:packages/sources/tenbin"\ + },\ {\ "name": "@chainlink/the-network-firm-adapter",\ "reference": "workspace:packages/sources/the-network-firm"\ @@ -822,6 +826,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/synthetix-debt-pool-adapter", ["workspace:packages/sources/synthetix-debt-pool"]],\ ["@chainlink/synthetix-feeds-adapter", ["workspace:packages/sources/synthetix-feeds"]],\ ["@chainlink/t-rize-proof-of-insurance-adapter", ["workspace:packages/sources/t-rize-proof-of-insurance"]],\ + ["@chainlink/tenbin-adapter", ["workspace:packages/sources/tenbin"]],\ ["@chainlink/the-network-firm-adapter", ["workspace:packages/sources/the-network-firm"]],\ ["@chainlink/tickerlayer-adapter", ["workspace:packages/sources/tickerlayer"]],\ ["@chainlink/tiingo-adapter", ["workspace:packages/sources/tiingo"]],\ @@ -7213,6 +7218,21 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/tenbin-adapter", [\ + ["workspace:packages/sources/tenbin", {\ + "packageLocation": "./packages/sources/tenbin/",\ + "packageDependencies": [\ + ["@chainlink/external-adapter-framework", "npm:2.19.1"],\ + ["@chainlink/tenbin-adapter", "workspace:packages/sources/tenbin"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["nock", "npm:13.5.6"],\ + ["tslib", "npm:2.4.1"],\ + ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/the-network-firm-adapter", [\ ["workspace:packages/sources/the-network-firm", {\ "packageLocation": "./packages/sources/the-network-firm/",\ diff --git a/packages/sources/tenbin/CHANGELOG.md b/packages/sources/tenbin/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/tenbin/README.md b/packages/sources/tenbin/README.md new file mode 100644 index 0000000000..4064bbf9b7 --- /dev/null +++ b/packages/sources/tenbin/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for tenbin + +This README will be generated automatically when code is merged to `main`. If you would like to generate a preview of the README, please run `yarn generate:readme tenbin`. diff --git a/packages/sources/tenbin/package.json b/packages/sources/tenbin/package.json new file mode 100644 index 0000000000..218bdb724a --- /dev/null +++ b/packages/sources/tenbin/package.json @@ -0,0 +1,40 @@ +{ + "name": "@chainlink/tenbin-adapter", + "version": "0.0.0", + "description": "Chainlink tenbin adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "tenbin" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "url": "https://github.com/smartcontractkit/external-adapters-js", + "type": "git" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", + "prepack": "yarn build", + "build": "tsc -b", + "server": "node -e 'require(\"./index.js\").server()'", + "server:dist": "node -e 'require(\"./dist/index.js\").server()'", + "start": "yarn server:dist" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.19.1", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/tenbin/src/config/index.ts b/packages/sources/tenbin/src/config/index.ts new file mode 100644 index 0000000000..c3c2cd6556 --- /dev/null +++ b/packages/sources/tenbin/src/config/index.ts @@ -0,0 +1,31 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + API_KEY: { + description: 'An API key for Data Provider', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'An API endpoint for Data Provider', + type: 'string', + default: 'https://public-api.tenbin.xyz/v1/verifier/attest', + sensitive: false, + }, + BUILD_JSON_ENDPOINT: { + description: 'URL of the build.json file containing the image digest for verification', + type: 'string', + default: + 'https://github.com/tenbinlabs/verification-service-releases/releases/latest/download/build.json', + sensitive: false, + }, + + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 10_000, + sensitive: false, + }, +}) diff --git a/packages/sources/tenbin/src/endpoint/index.ts b/packages/sources/tenbin/src/endpoint/index.ts new file mode 100644 index 0000000000..02f9806828 --- /dev/null +++ b/packages/sources/tenbin/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as verifiedBalance } from './verified-balance' diff --git a/packages/sources/tenbin/src/endpoint/verified-balance.ts b/packages/sources/tenbin/src/endpoint/verified-balance.ts new file mode 100644 index 0000000000..0f3e0cb805 --- /dev/null +++ b/packages/sources/tenbin/src/endpoint/verified-balance.ts @@ -0,0 +1,24 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { customSubscriptionTransport } from '../transport/verified-balance' + +export const inputParameters = new InputParameters({}) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: { + Result: string + Data: { + result: string + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'verified-balance', + aliases: [], + transport: customSubscriptionTransport, + inputParameters, +}) diff --git a/packages/sources/tenbin/src/index.ts b/packages/sources/tenbin/src/index.ts new file mode 100644 index 0000000000..01f257b4db --- /dev/null +++ b/packages/sources/tenbin/src/index.ts @@ -0,0 +1,13 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { verifiedBalance } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: verifiedBalance.name, + name: 'TENBIN', + config, + endpoints: [verifiedBalance], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/tenbin/src/transport/util.ts b/packages/sources/tenbin/src/transport/util.ts new file mode 100644 index 0000000000..40baa287a9 --- /dev/null +++ b/packages/sources/tenbin/src/transport/util.ts @@ -0,0 +1,266 @@ +import crypto from 'crypto' +import { AttestResponse, BuildJsonResponse } from './verified-balance' + +// From: https://confidentialcomputing.googleapis.com/.well-known/confidential_space_root.crt +export const PINNED_ROOT_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIGCDCCA/CgAwIBAgITYBvRy5g9aYYMh7tJS7pFwafL6jANBgkqhkiG9w0BAQsF +ADCBizELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcT +DU1vdW50YWluIFZpZXcxEzARBgNVBAoTCkdvb2dsZSBMTEMxFTATBgNVBAsTDEdv +b2dsZSBDbG91ZDEjMCEGA1UEAxMaQ29uZmlkZW50aWFsIFNwYWNlIFJvb3QgQ0Ew +HhcNMjQwMTE5MjIxMDUwWhcNMzQwMTE2MjIxMDQ5WjCBizELMAkGA1UEBhMCVVMx +EzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcxEzAR +BgNVBAoTCkdvb2dsZSBMTEMxFTATBgNVBAsTDEdvb2dsZSBDbG91ZDEjMCEGA1UE +AxMaQ29uZmlkZW50aWFsIFNwYWNlIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQCvRuZasczAqhMZe1ODHJ6MFLX8EYVV+RN7xiO9GpuA53iz +l9Oxgp3NXik3FbYn+7bcIkMMSQpCr6K0jbSQCZT6d5P5PJT5DpNGYjLHkW67/fl+ +Bu7eSMb0qRCa1jS+3OhNK7t7SIaHm1XdmSRghjwoglKRuk3CGrF4Zia9RcE/p2MU +69GyJZpqHYwTplNr3x4zF+2nJk86GywDP+sGwSPWfcmqY04VQD7ZPDEZZ/qgzdoL +5ilE92eQnAsy+6m6LxBEHHVcFpfDtNVUIt2VMCWLBeOKUQcn5js756xblInqw/Qt +QRR0An0yfRjBuGvmMjAwETDo5ETY/fc+nbQVYJzNQTc9EOpFFWPpw/ZjFcN9Amnd +dxYUETFXPmBYerMez0LKNtGpfKYHHhMMTI3mj0m/V9fCbfh2YbBUnMS2Swd20YSI +Mi/HiGaqOpGUqXMeQVw7phGTS3QYK8ZM65sC/QhIQzXdsiLDgFBitVnlIu3lIv6C +uiHvXeSJBRlRxQ8Vu+t6J7hBdl0etWBKAu9Vti46af5cjC03dspkHR3MAUGcrLWE +TkQ0msQAKvIAlwyQRLuQOI5D6pF+6af1Nbl+vR7sLCbDWdMqm1E9X6KyFKd6e3rn +E9O4dkFJp35WvR2gqIAkUoa+Vq1MXLFYG4imanZKH0igrIblbawRCr3Gr24FXQID +AQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQUF+fBOE6Th1snpKuvIb6S8/mtPL4wHwYDVR0jBBgwFoAUF+fBOE6Th1snpKuv +Ib6S8/mtPL4wDQYJKoZIhvcNAQELBQADggIBAGtCuV5eHxWcffylK9GPumaD6Yjd +cs76KDBe3mky5ItBIrEOeZq3z47zM4dbKZHhFuoq4yAaO1MyApnG0w9wIQLBDndI +ovtkw6j9/64aqPWpNaoB5MB0SahCUCgI83Dx9SRqGmjPI/MTMfwDLdE5EF9gFmVI +oH62YnG2aa/sc6m/8wIK8WtTJazEI16/8GPG4ZUhwT6aR3IGGnEBPMbMd5VZQ0Hw +VbHBKWK3UykaSCxnEg8uaNx/rhNaOWuWtos4qL00dYyGV7ZXg4fpAq7244QUgkWV +AtVcU2SPBjDd30OFHASnenDHRzQdOtHaxLp4a4WaY3jb2V6Sn3LfE8zSy6GevxmN +COIWW3xnPF8rwKz4ABEPqECe37zzu3W1nzZAFtdkhPBNnlWYkIusTMtU+8v6EPKp +GIIRphpaDhtGPJQukpENOfk2728lenPycRfjxwA96UKWq0dKZC45MwBEK9Jngn8Q +cPmpPmx7pSMkSxEX2Vos2JNaNmCKJd2VaXz8M6F2cxscRdh9TbAYAjGEEjE1nLUH +2YHDS8Y7xYNFIDSFaJAlqGcCUbzjGhrwHGj4voTe9ZvlmngrcA/ptSuBidvsnRDw +kNPLowCd0NqxYYSLNL7GroYCFPxoBpr+++4vsCaXalbs8iJxdU2EPqG4MB4xWKYg +uyT5CnJulxSC5CT1 +-----END CERTIFICATE-----` + +export const PINNED = { + ROOT_CERTIFICATE: PINNED_ROOT_CERTIFICATE, + ISS: 'https://confidentialcomputing.googleapis.com', + AUD: 'https://public-api.tenbin.xyz/v1/verifier', + SWNAME: 'CONFIDENTIAL_SPACE', + DBGSTAT: 'disabled-since-boot', +} + +const typeOf = (value: unknown): string => { + if (value === null) { + return 'null' + } + if (Array.isArray(value)) { + return 'array' + } + return typeof value +} + +// Verifies that two values in a record are deeply equal. Throws an error if +// they are not. Uses the keys of the record to provide context in the error +// message. +export const verifyDeepEquals = (map: Record): void => { + const entries = Object.entries(map) + if (entries.length !== 2) { + throw new Error(`Expected exactly two keys in the map, but got ${entries.length}`) + } + const [name1, value1] = entries[0]! + const [name2, value2] = entries[1]! + verifyDeepEqualsRecursive(value1, value2, name1!, name2!) +} + +const verifyDeepEqualsRecursive = ( + value1: unknown, + value2: unknown, + name1: string, + name2: string, +): void => { + if (value1 === value2) { + return + } + + const type1 = typeOf(value1) + const type2 = typeOf(value2) + verifyDeepEqualsRecursive(type1, type2, `type of ${name1}`, `type of ${name2}`) + if (['number', 'string', 'boolean', 'undefined', 'null'].includes(type1)) { + throw new Error( + `Mismatch at ${name1} is ${JSON.stringify(value1)}; ${name2} is ${JSON.stringify(value2)}`, + ) + } + if (type1 === 'array') { + const arr1 = value1 as unknown[] + const arr2 = value2 as unknown[] + verifyDeepEqualsRecursive(arr1.length, arr2.length, `${name1}.length`, `${name2}.length`) + for (let i = 0; i < arr1.length; i++) { + verifyDeepEqualsRecursive(arr1[i], arr2[i], `${name1}[${i}]`, `${name2}[${i}]`) + } + return + } + if (type1 === 'object') { + const obj1Keys = Object.keys(value1 as Record) + const obj2Keys = Object.keys(value2 as Record) + verifyDeepEqualsRecursive(obj1Keys.sort(), obj2Keys.sort(), `keys(${name1})`, `keys(${name2})`) + for (const key of obj1Keys) { + verifyDeepEqualsRecursive( + (value1 as Record)[key], + (value2 as Record)[key], + `${name1}.${key}`, + `${name2}.${key}`, + ) + } + return + } + throw new Error(`Unsupported type at ${name1} and ${name2}: ${type1}`) +} + +const verifyCanonicalMatchesData = (attestResponse: AttestResponse): void => { + let canonical: unknown + try { + canonical = JSON.parse(attestResponse.canonical) + } catch (e) { + throw new Error(`Failed to parse canonical data: ${e}`) + } + + const data = attestResponse.data + + verifyDeepEquals({ canonical, data }) +} + +const verifyCanonicalHashMatchesNonce = (attestResponse: AttestResponse): void => { + const canonical = attestResponse.canonical + const canonicalHash = crypto.createHash('sha256').update(canonical).digest('hex') + const nonce = attestResponse.nonce + verifyDeepEquals({ canonicalHash, nonce }) +} + +const verifyCertificates = (x5c: string[]): crypto.X509Certificate[] => { + if (!Array.isArray(x5c) || x5c.length === 0) { + throw new Error('Invalid x5c: expected a non-empty array of strings') + } + if (!x5c.every((cert) => typeof cert === 'string')) { + throw new Error('Invalid x5c: all entries must be strings') + } + const certs = x5c.map((b64) => new crypto.X509Certificate(base64UrlDecode(b64))) + certs.push(new crypto.X509Certificate(PINNED.ROOT_CERTIFICATE)) + + const now = Date.now() + for (const cert of certs) { + const validFrom = new Date(cert.validFrom).getTime() + const validTo = new Date(cert.validTo).getTime() + if (now < validFrom) { + throw new Error(`Certificate is not yet valid: validFrom is ${cert.validFrom}`) + } + if (now > validTo) { + throw new Error(`Certificate has expired: validTo is ${cert.validTo}`) + } + } + + for (let i = 0; i < certs.length - 1; i++) { + const cert = certs[i]! + const issuerCert = certs[i + 1]! + if (!cert.checkIssued(issuerCert)) { + throw new Error( + `Certificate chain is broken at index ${i}: issuer does not match subject of next certificate`, + ) + } + if (!cert.verify(issuerCert.publicKey)) { + throw new Error(`Certificate chain is broken at index ${i}: signature verification failed`) + } + } + + return certs +} + +const verifySignature = (message: Buffer, signature: Buffer, publicKey: crypto.KeyObject): void => { + if (!crypto.verify('sha256', message, { key: publicKey }, signature)) { + throw new Error('Signature verification failed') + } +} + +const verifyJwtBody = (body: Record): void => { + const { iss, aud, exp, nbf, iat, swname, dbgstat } = body + verifyDeepEquals({ iss, pinnedIss: PINNED.ISS }) + verifyDeepEquals({ aud, pinnedAud: PINNED.AUD }) + + const nowSeconds = Math.floor(Date.now() / 1000) + if (nowSeconds > Number(exp)) { + throw new Error(`JWT has expired: exp is ${exp}, now is ${nowSeconds}`) + } + if (nowSeconds < Number(nbf)) { + throw new Error(`JWT is not yet valid: nbf is ${nbf}, now is ${nowSeconds}`) + } + if (nowSeconds < Number(iat)) { + throw new Error(`JWT was issued in the future: iat is ${iat}, now is ${nowSeconds}`) + } + + verifyDeepEquals({ swname, pinnedSwname: PINNED.SWNAME }) + verifyDeepEquals({ dbgstat, pinnedDbgstat: PINNED.DBGSTAT }) +} + +const base64UrlDecode = (b64: string): Buffer => { + return Buffer.from(b64, 'base64') +} + +const verifyJwt = ( + attestResponse: AttestResponse, +): { + header: Record + body: Record + signature: Buffer +} => { + const { jwt } = attestResponse.attestation + const parts = jwt.split('.') + if (parts.length !== 3) { + throw new Error(`Invalid number of parts in JWT: expected 3, got ${parts.length}`) + } + const header = JSON.parse(base64UrlDecode(parts[0]!).toString('utf-8')) + const body = JSON.parse(base64UrlDecode(parts[1]!).toString('utf-8')) + const signature = base64UrlDecode(parts[2]!) + + verifyDeepEquals({ headerAlg: header.alg, expectedAlg: 'RS256' }) + + const certs = verifyCertificates(header.x5c) + verifySignature(Buffer.from(`${parts[0]}.${parts[1]}`), signature, certs[0]!.publicKey) + + verifyJwtBody(body) + + return { + header, + body, + signature, + } +} + +export const verifyJwtBodyEatsNonce = (body: Record, nonce: string): void => { + const eatNonce = Array.isArray(body.eat_nonce) ? body.eat_nonce : [body.eat_nonce] + if ( + !eatNonce.some( + (eaten) => typeof eaten === 'string' && eaten.toLowerCase() === nonce.toLowerCase(), + ) + ) { + throw new Error(`JWT body does not eat the nonce: ${nonce}. Eaten: ${eatNonce.join(', ')}`) + } +} + +const verifyBuildJson = ( + body: Record, + buildJsonResponse: BuildJsonResponse, +): void => { + const imageDigest = (body as { submods?: { container: { image_digest: string } } }).submods + ?.container.image_digest + const buildJsonImageDigest = buildJsonResponse.image_digest + verifyDeepEquals({ imageDigest, buildJsonImageDigest }) +} + +// This implements the verification steps from VERIFIER.md from Tenbin's +// private repo. +export const verifyAttestResponse = ( + attestResponse: AttestResponse, + buildJsonResponse: BuildJsonResponse, +): void => { + verifyCanonicalMatchesData(attestResponse) + verifyCanonicalHashMatchesNonce(attestResponse) + const { body: jwtBody } = verifyJwt(attestResponse) + verifyJwtBodyEatsNonce(jwtBody, attestResponse.nonce) + verifyBuildJson(jwtBody, buildJsonResponse) +} diff --git a/packages/sources/tenbin/src/transport/verified-balance.ts b/packages/sources/tenbin/src/transport/verified-balance.ts new file mode 100644 index 0000000000..54a31abd2a --- /dev/null +++ b/packages/sources/tenbin/src/transport/verified-balance.ts @@ -0,0 +1,138 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' +import { BaseEndpointTypes, inputParameters } from '../endpoint/verified-balance' +import { verifyAttestResponse } from './util' + +const logger = makeLogger('VerifiedBalanceTransport') + +type RequestParams = typeof inputParameters.validated + +export type AttestResponse = { + data: { + // ... + agg_usd_computed_balance: string + } + canonical: string + nonce: string + attestation: { + jwt: string + audience: string + token_type: string + } +} + +export type BuildJsonResponse = { + release_tag: string + source_report: string + commit: string + short_commit: string + image: string + image_digest: string + endpoint: string +} + +export class VerifiedBalanceTransport extends SubscriptionTransport { + endpointName!: string + config!: BaseEndpointTypes['Settings'] + requester!: Requester + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.config = adapterSettings + this.endpointName = endpointName + this.requester = dependencies.requester + } + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (param) => this.handleRequest(param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(param: RequestParams) { + let response: AdapterResponse + try { + response = await this._handleRequest(param) + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: (e as AdapterError)?.statusCode || 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + _params: RequestParams, + ): Promise> { + const providerDataRequestedUnixMs = Date.now() + + const [attestResponse, buildJsonResponse] = await Promise.all([ + this.getAttestResponse(), + this.getBuildJsonResponse(), + ]) + + verifyAttestResponse(attestResponse, buildJsonResponse) + + const { data } = attestResponse + const result = data.agg_usd_computed_balance + + return { + data: { + result, + }, + statusCode: 200, + result, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + async getAttestResponse(): Promise { + const requestConfig = { + method: 'GET', + baseURL: this.config.API_ENDPOINT, + headers: { + Authorization: `Bearer ${this.config.API_KEY}`, + }, + } + + const requestKey = this.config.API_ENDPOINT + const response = await this.requester.request(requestKey, requestConfig) + return response.response.data + } + + async getBuildJsonResponse(): Promise { + const requestConfig = { + method: 'GET', + baseURL: this.config.BUILD_JSON_ENDPOINT, + } + + const requestKey = this.config.BUILD_JSON_ENDPOINT + const response = await this.requester.request(requestKey, requestConfig) + return response.response.data + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const customSubscriptionTransport = new VerifiedBalanceTransport() diff --git a/packages/sources/tenbin/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/tenbin/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..7b48f18c0b --- /dev/null +++ b/packages/sources/tenbin/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute verified-balance endpoint should return success 1`] = ` +{ + "data": { + "result": "391337.57056944835533043965510", + }, + "result": "391337.57056944835533043965510", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1788269613333, + "providerDataRequestedUnixMs": 1788269613333, + }, +} +`; diff --git a/packages/sources/tenbin/test/integration/adapter.test.ts b/packages/sources/tenbin/test/integration/adapter.test.ts new file mode 100644 index 0000000000..0807f60679 --- /dev/null +++ b/packages/sources/tenbin/test/integration/adapter.test.ts @@ -0,0 +1,54 @@ +import { SettingsDefinitionFromConfig } from '@chainlink/external-adapter-framework/config' +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { config } from '../../src/config' +import { mockPostResponseSuccess } from './fixtures' + +type SettingsDefinition = SettingsDefinitionFromConfig + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.API_ENDPOINT = 'https://api.com/attest' + process.env.API_KEY = process.env.API_KEY ?? 'fake-api-key' + process.env.BUILD_JSON_ENDPOINT = 'https://api.com/build.json' + process.env.BACKGROUND_EXECUTE_MS = process.env.BACKGROUND_EXECUTE_MS ?? '0' + const mockDate = new Date('2026-09-01T13:33:33.333Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('verified-balance endpoint', () => { + it('should return success', async () => { + const data = { + endpoint: 'verified-balance', + } + + mockPostResponseSuccess() + + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(200) + }) + }) +}) diff --git a/packages/sources/tenbin/test/integration/fixtures.ts b/packages/sources/tenbin/test/integration/fixtures.ts new file mode 100644 index 0000000000..f596b16957 --- /dev/null +++ b/packages/sources/tenbin/test/integration/fixtures.ts @@ -0,0 +1,28 @@ +import fs from 'fs' +import nock from 'nock' +import path from 'path' + +const readFixture = (name: string): unknown => + JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'unit', name), 'utf-8')) + +const attestResponse = readFixture('attest-response-2026-09-01.json') +const buildJsonResponse = readFixture('github-build-2026-09-03.json') + +const headers = [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', +] + +export const mockPostResponseSuccess = (): nock.Scope => + nock('https://api.com') + .persist() + .get('/attest') + .reply(200, () => attestResponse, headers) + .get('/build.json') + .reply(200, () => buildJsonResponse, headers) diff --git a/packages/sources/tenbin/test/unit/attest-response-2026-09-01.json b/packages/sources/tenbin/test/unit/attest-response-2026-09-01.json new file mode 100644 index 0000000000..b9269e464c --- /dev/null +++ b/packages/sources/tenbin/test/unit/attest-response-2026-09-01.json @@ -0,0 +1,295 @@ +{ + "data": { + "timestamp": "2026-09-01T13:17:18.081103664Z", + "assets": [ + { + "asset_id": "gold", + "token_symbol": "tGLD", + "balances": [ + { + "broker": "hidden_road", + "venue": "cme", + "account": "71800F0001:CME_SUBACCOUNTS", + "asset": "USD", + "quantity": "-3156.46" + }, + { + "broker": "hidden_road", + "venue": "cme", + "account": "71800F0000:CME_SUBACCOUNTS", + "asset": "USD", + "quantity": "28029.96" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TNBNSVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "usd", + "quantity": "499.74061949" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TNBNSVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "usdc", + "quantity": "28886.400643" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TNBNSVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "eth", + "quantity": "-0.002429498631227504" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TNBNSVG:RPLMASTERUS_ATM_FEE_ROLLUP_ACCOUNTS", + "asset": "eth", + "quantity": "-0.00002720807390363199" + }, + { + "broker": "hidden_road", + "venue": "hrpmasterusfcm", + "account": "TNBNSVG:HRPMASTERUSFCM_UNIVERSAL_ACCOUNTS", + "asset": "usd", + "quantity": "-0.0000000000000000003449017197" + }, + { + "broker": "hidden_road", + "venue": "hrpmasterfincous", + "account": "TNBNSVG:HRPMASTERFINCOUS_UNIVERSAL_ACCOUNTS", + "asset": "usd", + "quantity": "-0.33931331959016736" + } + ], + "positions": [ + { + "broker": "hidden_road", + "venue": "cme", + "account": "71800F0001:CME_SUBACCOUNTS", + "instrument": "MGCZ6 Future", + "quantity": "14", + "unrealized_pnl": "41063", + "unrealized_pnl_asset": "usd" + } + ], + "summary": { + "broker_count": 1, + "total_balance_value": [ + { + "asset": "ETH", + "quantity": "-0.00245670670513113599" + }, + { + "asset": "USD", + "quantity": "25372.901306170409832639655098" + }, + { + "asset": "USDC", + "quantity": "28886.400643" + } + ], + "total_net_position": [ + { + "instrument": "MGCZ6 FUTURE", + "net_quantity": "14" + } + ], + "num_tokens_backed": "139.736060351714162211", + "usd_computed_balance": "95322.30194917040983263965510" + } + }, + { + "asset_id": "brl", + "token_symbol": "tBRL", + "balances": [ + { + "broker": "hidden_road", + "venue": "cme", + "account": "71810F0001:CME_SUBACCOUNTS", + "asset": "USD", + "quantity": "16503.26" + }, + { + "broker": "hidden_road", + "venue": "cme", + "account": "71810F0000:CME_SUBACCOUNTS", + "asset": "USD", + "quantity": "184621.74" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TEBIIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "usd", + "quantity": "499.76684781" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TEBIIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "usdc", + "quantity": "28301.59" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TEBIIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "eth", + "quantity": "-0.000904050027308072" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TEBIIVG:RPLMASTERUS_ATM_FEE_ROLLUP_ACCOUNTS", + "asset": "eth", + "quantity": "-0.000012414515677664026" + }, + { + "broker": "hidden_road", + "venue": "hrpmasterfincous", + "account": "TEBIIVG:HRPMASTERFINCOUS_UNIVERSAL_ACCOUNTS", + "asset": "usd", + "quantity": "-0.2558375445074222" + } + ], + "positions": [ + { + "broker": "hidden_road", + "venue": "cme", + "account": "71810F0001:CME_SUBACCOUNTS", + "instrument": "6LV6 Future", + "quantity": "80", + "unrealized_pnl": "-5325", + "unrealized_pnl_asset": "usd" + } + ], + "summary": { + "broker_count": 1, + "total_balance_value": [ + { + "asset": "ETH", + "quantity": "-0.000916464542985736026" + }, + { + "asset": "USD", + "quantity": "201624.5110102654925778" + }, + { + "asset": "USDC", + "quantity": "28301.59" + } + ], + "total_net_position": [ + { + "instrument": "6LV6 FUTURE", + "net_quantity": "80" + } + ], + "num_tokens_backed": "8048737.057392338446283721", + "usd_computed_balance": "224601.1010102654925778" + } + }, + { + "asset_id": "mxn", + "token_symbol": "tMXN", + "balances": [ + { + "broker": "hidden_road", + "venue": "cme", + "account": "71820F0001:CME_SUBACCOUNTS", + "asset": "USD", + "quantity": "-480.47" + }, + { + "broker": "hidden_road", + "venue": "cme", + "account": "71820F0000:CME_SUBACCOUNTS", + "asset": "USD", + "quantity": "35645.47" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TENBIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "usd", + "quantity": "33.80336354" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TENBIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "usdc", + "quantity": "18354.200000000008" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TENBIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS", + "asset": "eth", + "quantity": "-0.000163553675415364" + }, + { + "broker": "hidden_road", + "venue": "rplmasterus", + "account": "TENBIVG:RPLMASTERUS_ATM_FEE_ROLLUP_ACCOUNTS", + "asset": "eth", + "quantity": "-0.0000000000000000000020250846" + }, + { + "broker": "hidden_road", + "venue": "hrpmasterfincous", + "account": "TENBIVG:HRPMASTERFINCOUS_UNIVERSAL_ACCOUNTS", + "asset": "usd", + "quantity": "-33.83575352755508" + } + ], + "positions": [ + { + "broker": "hidden_road", + "venue": "cme", + "account": "71820F0001:CME_SUBACCOUNTS", + "instrument": "6MU6 Future", + "quantity": "18", + "unrealized_pnl": "17895", + "unrealized_pnl_asset": "usd" + } + ], + "summary": { + "broker_count": 1, + "total_balance_value": [ + { + "asset": "ETH", + "quantity": "-0.0001635536754153640020250846" + }, + { + "asset": "USD", + "quantity": "35164.96761001244492" + }, + { + "asset": "USDC", + "quantity": "18354.200000000008" + } + ], + "total_net_position": [ + { + "instrument": "6MU6 FUTURE", + "net_quantity": "18" + } + ], + "num_tokens_backed": "9030109.860668877081476812", + "usd_computed_balance": "71414.16761001245292" + } + } + ], + "agg_usd_computed_balance": "391337.57056944835533043965510" + }, + "canonical": "{\"timestamp\":\"2026-09-01T13:17:18.081103664Z\",\"assets\":[{\"asset_id\":\"gold\",\"token_symbol\":\"tGLD\",\"balances\":[{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71800F0001:CME_SUBACCOUNTS\",\"asset\":\"USD\",\"quantity\":\"-3156.46\"},{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71800F0000:CME_SUBACCOUNTS\",\"asset\":\"USD\",\"quantity\":\"28029.96\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TNBNSVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usd\",\"quantity\":\"499.74061949\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TNBNSVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usdc\",\"quantity\":\"28886.400643\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TNBNSVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"eth\",\"quantity\":\"-0.002429498631227504\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TNBNSVG:RPLMASTERUS_ATM_FEE_ROLLUP_ACCOUNTS\",\"asset\":\"eth\",\"quantity\":\"-0.00002720807390363199\"},{\"broker\":\"hidden_road\",\"venue\":\"hrpmasterusfcm\",\"account\":\"TNBNSVG:HRPMASTERUSFCM_UNIVERSAL_ACCOUNTS\",\"asset\":\"usd\",\"quantity\":\"-0.0000000000000000003449017197\"},{\"broker\":\"hidden_road\",\"venue\":\"hrpmasterfincous\",\"account\":\"TNBNSVG:HRPMASTERFINCOUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usd\",\"quantity\":\"-0.33931331959016736\"}],\"positions\":[{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71800F0001:CME_SUBACCOUNTS\",\"instrument\":\"MGCZ6 Future\",\"quantity\":\"14\",\"unrealized_pnl\":\"41063\",\"unrealized_pnl_asset\":\"usd\"}],\"summary\":{\"broker_count\":1,\"total_balance_value\":[{\"asset\":\"ETH\",\"quantity\":\"-0.00245670670513113599\"},{\"asset\":\"USD\",\"quantity\":\"25372.901306170409832639655098\"},{\"asset\":\"USDC\",\"quantity\":\"28886.400643\"}],\"total_net_position\":[{\"instrument\":\"MGCZ6 FUTURE\",\"net_quantity\":\"14\"}],\"num_tokens_backed\":\"139.736060351714162211\",\"usd_computed_balance\":\"95322.30194917040983263965510\"}},{\"asset_id\":\"brl\",\"token_symbol\":\"tBRL\",\"balances\":[{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71810F0001:CME_SUBACCOUNTS\",\"asset\":\"USD\",\"quantity\":\"16503.26\"},{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71810F0000:CME_SUBACCOUNTS\",\"asset\":\"USD\",\"quantity\":\"184621.74\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TEBIIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usd\",\"quantity\":\"499.76684781\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TEBIIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usdc\",\"quantity\":\"28301.59\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TEBIIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"eth\",\"quantity\":\"-0.000904050027308072\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TEBIIVG:RPLMASTERUS_ATM_FEE_ROLLUP_ACCOUNTS\",\"asset\":\"eth\",\"quantity\":\"-0.000012414515677664026\"},{\"broker\":\"hidden_road\",\"venue\":\"hrpmasterfincous\",\"account\":\"TEBIIVG:HRPMASTERFINCOUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usd\",\"quantity\":\"-0.2558375445074222\"}],\"positions\":[{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71810F0001:CME_SUBACCOUNTS\",\"instrument\":\"6LV6 Future\",\"quantity\":\"80\",\"unrealized_pnl\":\"-5325\",\"unrealized_pnl_asset\":\"usd\"}],\"summary\":{\"broker_count\":1,\"total_balance_value\":[{\"asset\":\"ETH\",\"quantity\":\"-0.000916464542985736026\"},{\"asset\":\"USD\",\"quantity\":\"201624.5110102654925778\"},{\"asset\":\"USDC\",\"quantity\":\"28301.59\"}],\"total_net_position\":[{\"instrument\":\"6LV6 FUTURE\",\"net_quantity\":\"80\"}],\"num_tokens_backed\":\"8048737.057392338446283721\",\"usd_computed_balance\":\"224601.1010102654925778\"}},{\"asset_id\":\"mxn\",\"token_symbol\":\"tMXN\",\"balances\":[{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71820F0001:CME_SUBACCOUNTS\",\"asset\":\"USD\",\"quantity\":\"-480.47\"},{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71820F0000:CME_SUBACCOUNTS\",\"asset\":\"USD\",\"quantity\":\"35645.47\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TENBIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usd\",\"quantity\":\"33.80336354\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TENBIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usdc\",\"quantity\":\"18354.200000000008\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TENBIVG:RPLMASTERUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"eth\",\"quantity\":\"-0.000163553675415364\"},{\"broker\":\"hidden_road\",\"venue\":\"rplmasterus\",\"account\":\"TENBIVG:RPLMASTERUS_ATM_FEE_ROLLUP_ACCOUNTS\",\"asset\":\"eth\",\"quantity\":\"-0.0000000000000000000020250846\"},{\"broker\":\"hidden_road\",\"venue\":\"hrpmasterfincous\",\"account\":\"TENBIVG:HRPMASTERFINCOUS_UNIVERSAL_ACCOUNTS\",\"asset\":\"usd\",\"quantity\":\"-33.83575352755508\"}],\"positions\":[{\"broker\":\"hidden_road\",\"venue\":\"cme\",\"account\":\"71820F0001:CME_SUBACCOUNTS\",\"instrument\":\"6MU6 Future\",\"quantity\":\"18\",\"unrealized_pnl\":\"17895\",\"unrealized_pnl_asset\":\"usd\"}],\"summary\":{\"broker_count\":1,\"total_balance_value\":[{\"asset\":\"ETH\",\"quantity\":\"-0.0001635536754153640020250846\"},{\"asset\":\"USD\",\"quantity\":\"35164.96761001244492\"},{\"asset\":\"USDC\",\"quantity\":\"18354.200000000008\"}],\"total_net_position\":[{\"instrument\":\"6MU6 FUTURE\",\"net_quantity\":\"18\"}],\"num_tokens_backed\":\"9030109.860668877081476812\",\"usd_computed_balance\":\"71414.16761001245292\"}}],\"agg_usd_computed_balance\":\"391337.57056944835533043965510\"}", + "nonce": "916ce327f57db5c8ee5aeb00c86193fc01d9ff85578d76f07f748f939b852a82", + "attestation": { + "jwt": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkcwMTJkaDhLM1RQaHJWZkU2SVc0V3Y4b0J4OFFGbXM5aF9qRzB2ZWlzaUUiLCJ0eXAiOiJKV1QiLCJ4NWMiOlsiTUlJRm1UQ0NBNEdnQXdJQkFnSVVBSm1jYm44UzZOUmRrRDdqNGpuQmltVzQ2aXN3RFFZSktvWklodmNOQVFFTEJRQXdnWk14Q3pBSkJnTlZCQVlUQWxWVE1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGhNUll3RkFZRFZRUUhFdzFOYjNWdWRHRnBiaUJXYVdWM01STXdFUVlEVlFRS0V3cEhiMjluYkdVZ1RFeERNUlV3RXdZRFZRUUxFd3hIYjI5bmJHVWdRMnh2ZFdReEt6QXBCZ05WQkFNVElrTnZibVpwWkdWdWRHbGhiQ0JUY0dGalpTQkpiblJsY20xbFpHbGhkR1VnUTBFd0hoY05Nall3T0RNeE1UUXlPRFUwV2hjTk1qWXhNRE13TVRReU9EVXpXakNCbERFTE1Ba0dBMVVFQmhNQ1ZWTXhFekFSQmdOVkJBZ1RDa05oYkdsbWIzSnVhV0V4RmpBVUJnTlZCQWNURFUxdmRXNTBZV2x1SUZacFpYY3hFekFSQmdOVkJBb1RDa2R2YjJkc1pTQk1URU14RlRBVEJnTlZCQXNUREVkdmIyZHNaU0JEYkc5MVpERXNNQ29HQTFVRUF4TWpRMjl1Wm1sa1pXNTBhV0ZzSUZOd1lXTmxJRXhsWVdZZ1EyVnlkR2xtYVdOaGRHVXdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFDTGFObjBLbHpaM2o3OGdBSjBucU11c1dyMXo5cUJHeGs1c0NHRmQ4bUFMR2hIUjNOYVZpeEx6azBBWHA0QnVkOGZ3ZHNQdzRMdFJyY0JXcG5OUzBiSW1xVWlCQXRsTXFjN0FGVzA3TmNLSGpXOHZaUVE1VmVMUmpqcjIwRS9TWHJFQ2JWb3IyRHdCZVlXUjYrYmZIeERRakhMVngxcEpvL0tBZE5CMXdCaVNCTmNzRUN0K0UyK3hGK0ROS2NEQ1Uwb0lpVGRpTEh6QVoxUWliWjhwajM5TTgxcEZlV3g5cThlalFKL1dJczI0VHBKK21SajdhNVFHZjBUZlNCZEhKbmZicjhaelpxeFpoaG5BOU85NTZ5ZEREYWJ0d2h6cVBKbCtFTk9Hek9URm5MNkZxeHR1Ym5HOWpDY3VsQUp2WFoweVQ1YWx1akQybkZPckVTZlQ0OTlBZ01CQUFHamdlRXdnZDR3REFZRFZSMFRBUUgvQkFJd0FEQWRCZ05WSFE0RUZnUVVld3lMZ0xRM2hiQ3llaHRRS2tBa1h0SCt3R293SHdZRFZSMGpCQmd3Rm9BVVFkU0NxUDQxc1lhTU9KRWhqaTdlY0V1VSsyZ3dnWTBHQ0NzR0FRVUZCd0VCQklHQU1INHdmQVlJS3dZQkJRVUhNQUtHY0doMGRIQTZMeTl3Y21sMllYUmxZMkV0WTI5dWRHVnVkQzAyTldGak56VmlaQzB3TURBd0xUSTJPVEF0T1RnM05DMWtOR1kxTkRkbU9UUXdaV011YzNSdmNtRm5aUzVuYjI5bmJHVmhjR2x6TG1OdmJTODBNV05rTURjeU1ERXpOR0ZtWWpBeU5qUTJPQzlqWVM1amNuUXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnSUJBSk9GU0NkcURWOXFuY0RWeXBmbUlLYk5uSlpYNjExSWMyUmN6S1FWVE9FUUVhcS9hT1MweFRjdEJ5T2V4MThDRFAxdmQ3OXpLa0ZpRS9RN0FZSlpuMXo0a2t5ejUwNVk5TUN1bzh3VWFvVk12dnZBeWE5M1NybUlnT2UyN0tycnM3d21xSENWOUhLOGdSUUdDcmZDc0p0QVJUT1Q1RFBjalBFVzFkbjJUVFVKVVdJMjlmb1ovYWRtbnh3L3hXT1RiajRvUE53TzFXNlRVMkVCQ1crYUY2K0JEcnhyQkx2WnNMWlRsT3BsU2g3TTdkNGt2N0JMUDUwREJXZGZZaHd3U0NlaG8vWmdwcWRUdHB1SkN3UWRQakNKM3BXYWtuazg2SExzMmFZZGIvMGg3a0FYazBlRk9xSEZVOHE1WGxkSE5Yd09WUkw5enVrdmtsOUVkb2pEMno3N0NKTHQ3eXJZOWdJOEgzQ0FVTHNqWENpczdSbTdmUXgwU2JZU0h6VGhvMUo1YzVITGZ5WFduVGVlZ202M2hmN3NPYWcxTCs5NkhRSmlBSkZ2bU1MZGxwblNVWCttUWxUdjJJS2p0SHo2amlzMW51OGVoU0xMUkU3SVhTaDhaWGk0Z1dMaEtKc09UcDNIaGhCYkVrUEJ1R09DaU92TFppaUM4enVMRFBzMmt3NytUWEFGSTVMd1NQV3NYOGhUUHRtYjVpR1Rad1NmUlI0QzkyajR4OHppN3F1NWZoVjRQZTNDbjMzZkhBZnEwc1NDSlI5cUxCQ3NpUm4xTXkrcWhaNHJXRisrOG94U1Z3OUxYM0M5OUdyMW94WW5rV0lkMzB5QXEyYjJmSXJERGdteHhpNHE3a1BUSjFFNTBlT2ZBOE8yL3BqTy9ldHVVZW16UThiMTZQcUUiLCJNSUlIUERDQ0JTU2dBd0lCQWdJVUFPdzh4bFE4TjFpU0xwZWlqMGhNbHFucGZUQXdEUVlKS29aSWh2Y05BUUVMQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJNd0VRWURWUVFJRXdwRFlXeHBabTl5Ym1saE1SWXdGQVlEVlFRSEV3MU5iM1Z1ZEdGcGJpQldhV1YzTVJNd0VRWURWUVFLRXdwSGIyOW5iR1VnVEV4RE1SVXdFd1lEVlFRTEV3eEhiMjluYkdVZ1EyeHZkV1F4SXpBaEJnTlZCQU1UR2tOdmJtWnBaR1Z1ZEdsaGJDQlRjR0ZqWlNCU2IyOTBJRU5CTUI0WERUSTBNREV5TXpFNE1qWXpObG9YRFRJM01ERXlNekV4TlRJMU0xb3dnWk14Q3pBSkJnTlZCQVlUQWxWVE1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGhNUll3RkFZRFZRUUhFdzFOYjNWdWRHRnBiaUJXYVdWM01STXdFUVlEVlFRS0V3cEhiMjluYkdVZ1RFeERNUlV3RXdZRFZRUUxFd3hIYjI5bmJHVWdRMnh2ZFdReEt6QXBCZ05WQkFNVElrTnZibVpwWkdWdWRHbGhiQ0JUY0dGalpTQkpiblJsY20xbFpHbGhkR1VnUTBFd2dnSWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUNEd0F3Z2dJS0FvSUNBUURIYzJFM0dUNXFPczZZNGx5ays1NU9kNEhiWi9odW1KMEpTVFc0UHhQUlNHZnl3VWRDaU1iTDVjMnpyNm9IZTIwYzA3eTM4RXNoZ1NyVmtpSEVzdFQ4QkZBR2g1NmFzZ0txQlhBKzdSRnoyb05VaWFnblhXOW96M1RDbmh4cGw0RkFtRmgvK1cxSGlRYmtxSEdnOGdvbFNZK2pudjRVTEVJa3R1MHlFZHVsRzd0UXVzSzYrdEdvZ1NTcUhGclU1RVVnU1ZtQkF3SmZhT0IxVW5qVFhsa0xhYUlNckxJbHh3UzlSUEQ1dEFnc3ZiUzNEVDZIdW4yMVAzVk9SSEx1VjRDQ3VNcEhCdWlCUUxBVE84ZmhGWlN2RGFYVXEwalhyMXNSb0IrZEw4YXhuZDFPVTltSmREbmZ5RHJTckdtMUM4VXZQc3YvV0pUUGVkNGUweUNZay8yOXNJZzhQL3doeVBDNWc0SjRkL1U5MDFLZmJkNE9nZ1lDZ2ttRDdCYVZlS0YrWmVjKzd2YXBRSVVvTTM1ZTZBSHFEeG02dkFPaUZ2WGV0V0pkbUp3Y1l0TzQ2WEhMQXhGZkxvOVVPb3dkYjh3YXNBWDRuYW9ySjd2cUc5MzhFQ3hCbE1icmovVVcwYU1PeXJUVGhydXFJWFVPdlFzbVRuRnlUTCtpVzJRMzN6akFPM2xpekQ3UkxHY1krWFlFbVZ0RnlTYVB0Q1VGWTZjRTJPT3FwVCtUUHp1cis1bnJ6Yy9FbnZmczI5bXorZTNLQnZvRVhXTlBuVXM4eWdTbE9HdE5uZWMweFhsQTdYVXRxYU5mRUZQRFFCYjJ1WGRWRWl6TzVoenlkYjVRT1NHTEM2dEZTc0VwbjZrd21NeG93ZUI2TnVxczZWYitDOU9CWnlDNzQyY0l1UUlEQVFBQm80SUJqRENDQVlnd0RnWURWUjBQQVFIL0JBUURBZ0VHTUJBR0ExVWRKUVFKTUFjR0JXZUJCUWdCTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3SFFZRFZSME9CQllFRkVIVWdxaitOYkdHakRpUklZNHUzbkJMbFB0b01COEdBMVVkSXdRWU1CYUFGQmZud1RoT2s0ZGJKNlNycnlHK2t2UDVyVHkrTUlHTkJnZ3JCZ0VGQlFjQkFRU0JnREIrTUh3R0NDc0dBUVVGQnpBQ2huQm9kSFJ3T2k4dmNISnBkbUYwWldOaExXTnZiblJsYm5RdE5qVmhZVFkyWWpRdE1EQXdNQzB5TXpGakxXRm1Oakl0TVRSak1UUmxaVFkyTkRVMExuTjBiM0poWjJVdVoyOXZaMnhsWVhCcGN5NWpiMjB2WkRNeE5HVmxOR1ptTkRoa1l6TmpNalUxTUdFdlkyRXVZM0owTUlHQ0JnTlZIUjhFZXpCNU1IZWdkYUJ6aG5Gb2RIUndPaTh2Y0hKcGRtRjBaV05oTFdOdmJuUmxiblF0TmpWaFlUWTJZalF0TURBd01DMHlNekZqTFdGbU5qSXRNVFJqTVRSbFpUWTJORFUwTG5OMGIzSmhaMlV1WjI5dloyeGxZWEJwY3k1amIyMHZaRE14TkdWbE5HWm1ORGhrWXpOak1qVTFNR0V2WTNKc0xtTnliREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBZ0VBb2tib2w4L0Q0R1l2QU5hVnB2L1hXUEkrYUEwOThncmwvZmJVK3MzbTBIUFEyUlBYd00yNnVpb1NTQUZaTVhIcWU0eDlIa29LYlVMa1lzM0pja01jK0MvVVFyVGlSSTFodTkxRWNSL3VPZjFQSElWSUhXTXJnOHJhZTM0WWFlUk9adWMxM0h4emt4K1dhcEY2OVRTL1UvdWE3NGJ1UU93c1k1VjhXV1VoYTZxMHpiK1BPaUVVejBLbU5kYUw2UVo0am1wTkcvcTdlODloVWxHZnNaeHZZOUFzZzh0ZTZWT3RodjJMYjBNNVIrTDZDMHZ0Q2tvV0tvMU5abzJKdWp6WVJOQlhIVGtxd0hKTVpqbDNZZzFEVEJ1R3RMNGYvbDNFUWllNU1KL3BlQlh1d2xpVUJOeWhuWFRETmgvTmxpZTVXSmRWWFVQaXZxSGt1c2ptZEk4dm0renFCTllyVm91UUV6TDlNUlEwak9tTmlsUTN3YVVudGZQMGJ0ajdENDFBMHpIMUEvRkVEOG54S0Q4SURMd3VMeDBLdjVRYm9nMDZ3cW5TZkZrTURwTGFKMkNHK3JlZmNKaGtQS1UyVStwOVh6di92SEp1cWxUMUZDNkpQdytCbklTdnVBQThmUGUyaG1Gd1BXdjJDSDlJQlNTVWxLVURjdWp2U2o0alRTLzNYZVRNK1Zqd2sxSzdpM3k0aFpsaVMxQmcyQmZ0Q3RZZEVTQm9DWlBHcUhtbmF3cGUyL1JlT0VNZThHTFFaUWhPVWJ1MzlxcDh1MndRY0JuOU00K3NDN1BiZEQreGtCVWdwWlViQjVVQTkrNFQwMFNCaDhwR0hmYnhQVFpzbC9oNVFDa0dudHRYT1ZxYllyRFhEdnhkblJxYWlHQXUybVlxdWZIQVNSbFJpV3M9IiwiTUlJR0NEQ0NBL0NnQXdJQkFnSVRZQnZSeTVnOWFZWU1oN3RKUzdwRndhZkw2akFOQmdrcWhraUc5dzBCQVFzRkFEQ0JpekVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnVENrTmhiR2xtYjNKdWFXRXhGakFVQmdOVkJBY1REVTF2ZFc1MFlXbHVJRlpwWlhjeEV6QVJCZ05WQkFvVENrZHZiMmRzWlNCTVRFTXhGVEFUQmdOVkJBc1RERWR2YjJkc1pTQkRiRzkxWkRFak1DRUdBMVVFQXhNYVEyOXVabWxrWlc1MGFXRnNJRk53WVdObElGSnZiM1FnUTBFd0hoY05NalF3TVRFNU1qSXhNRFV3V2hjTk16UXdNVEUyTWpJeE1EUTVXakNCaXpFTE1Ba0dBMVVFQmhNQ1ZWTXhFekFSQmdOVkJBZ1RDa05oYkdsbWIzSnVhV0V4RmpBVUJnTlZCQWNURFUxdmRXNTBZV2x1SUZacFpYY3hFekFSQmdOVkJBb1RDa2R2YjJkc1pTQk1URU14RlRBVEJnTlZCQXNUREVkdmIyZHNaU0JEYkc5MVpERWpNQ0VHQTFVRUF4TWFRMjl1Wm1sa1pXNTBhV0ZzSUZOd1lXTmxJRkp2YjNRZ1EwRXdnZ0lpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElDRHdBd2dnSUtBb0lDQVFDdlJ1WmFzY3pBcWhNWmUxT0RISjZNRkxYOEVZVlYrUk43eGlPOUdwdUE1M2l6bDlPeGdwM05YaWszRmJZbis3YmNJa01NU1FwQ3I2SzBqYlNRQ1pUNmQ1UDVQSlQ1RHBOR1lqTEhrVzY3L2ZsK0J1N2VTTWIwcVJDYTFqUyszT2hOSzd0N1NJYUhtMVhkbVNSZ2hqd29nbEtSdWszQ0dyRjRaaWE5UmNFL3AyTVU2OUd5SlpwcUhZd1RwbE5yM3g0ekYrMm5Kazg2R3l3RFArc0d3U1BXZmNtcVkwNFZRRDdaUERFWlovcWd6ZG9MNWlsRTkyZVFuQXN5KzZtNkx4QkVISFZjRnBmRHROVlVJdDJWTUNXTEJlT0tVUWNuNWpzNzU2eGJsSW5xdy9RdFFSUjBBbjB5ZlJqQnVHdm1NakF3RVREbzVFVFkvZmMrbmJRVllKek5RVGM5RU9wRkZXUHB3L1pqRmNOOUFtbmRkeFlVRVRGWFBtQlllck1lejBMS050R3BmS1lISGhNTVRJM21qMG0vVjlmQ2JmaDJZYkJVbk1TMlN3ZDIwWVNJTWkvSGlHYXFPcEdVcVhNZVFWdzdwaEdUUzNRWUs4Wk02NXNDL1FoSVF6WGRzaUxEZ0ZCaXRWbmxJdTNsSXY2Q3VpSHZYZVNKQlJsUnhROFZ1K3Q2SjdoQmRsMGV0V0JLQXU5VnRpNDZhZjVjakMwM2RzcGtIUjNNQVVHY3JMV0VUa1EwbXNRQUt2SUFsd3lRUkx1UU9JNUQ2cEYrNmFmMU5ibCt2UjdzTENiRFdkTXFtMUU5WDZLeUZLZDZlM3JuRTlPNGRrRkpwMzVXdlIyZ3FJQWtVb2ErVnExTVhMRllHNGltYW5aS0gwaWdySWJsYmF3UkNyM0dyMjRGWFFJREFRQUJvMk13WVRBT0JnTlZIUThCQWY4RUJBTUNBUVl3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVRitmQk9FNlRoMXNucEt1dkliNlM4L210UEw0d0h3WURWUjBqQkJnd0ZvQVVGK2ZCT0U2VGgxc25wS3V2SWI2UzgvbXRQTDR3RFFZSktvWklodmNOQVFFTEJRQURnZ0lCQUd0Q3VWNWVIeFdjZmZ5bEs5R1B1bWFENllqZGNzNzZLREJlM21reTVJdEJJckVPZVpxM3o0N3pNNGRiS1pIaEZ1b3E0eUFhTzFNeUFwbkcwdzl3SVFMQkRuZElvdnRrdzZqOS82NGFxUFdwTmFvQjVNQjBTYWhDVUNnSTgzRHg5U1JxR21qUEkvTVRNZndETGRFNUVGOWdGbVZJb0g2MlluRzJhYS9zYzZtLzh3SUs4V3RUSmF6RUkxNi84R1BHNFpVaHdUNmFSM0lHR25FQlBNYk1kNVZaUTBId1ZiSEJLV0szVXlrYVNDeG5FZzh1YU54L3JoTmFPV3VXdG9zNHFMMDBkWXlHVjdaWGc0ZnBBcTcyNDRRVWdrV1ZBdFZjVTJTUEJqRGQzME9GSEFTbmVuREhSelFkT3RIYXhMcDRhNFdhWTNqYjJWNlNuM0xmRTh6U3k2R2V2eG1OQ09JV1czeG5QRjhyd0t6NEFCRVBxRUNlMzd6enUzVzFuelpBRnRka2hQQk5ubFdZa0l1c1RNdFUrOHY2RVBLcEdJSVJwaHBhRGh0R1BKUXVrcEVOT2ZrMjcyOGxlblB5Y1Jmanh3QTk2VUtXcTBkS1pDNDVNd0JFSzlKbmduOFFjUG1wUG14N3BTTWtTeEVYMlZvczJKTmFObUNLSmQyVmFYejhNNkYyY3hzY1JkaDlUYkFZQWpHRUVqRTFuTFVIMllIRFM4WTd4WU5GSURTRmFKQWxxR2NDVWJ6akdocndIR2o0dm9UZTladmxtbmdyY0EvcHRTdUJpZHZzblJEd2tOUExvd0NkME5xeFlZU0xOTDdHcm9ZQ0ZQeG9CcHIrKys0dnNDYVhhbGJzOGlKeGRVMkVQcUc0TUI0eFdLWWd1eVQ1Q25KdWx4U0M1Q1QxIl19.eyJhdWQiOiJodHRwczovL3B1YmxpYy1hcGkudGVuYmluLnh5ei92MS92ZXJpZmllciIsImV4cCI6MTc4ODI3MjI0OCwiaWF0IjoxNzg4MjY4NjQ4LCJpc3MiOiJodHRwczovL2NvbmZpZGVudGlhbGNvbXB1dGluZy5nb29nbGVhcGlzLmNvbSIsIm5iZiI6MTc4ODI2ODY0OCwic3ViIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vY29tcHV0ZS92MS9wcm9qZWN0cy90ZW5iaW4tcHJvZC96b25lcy91cy1jZW50cmFsMS1hL2luc3RhbmNlcy90ZW5iaW4tcHJvZC12ZXJpZmljYXRpb24tc2VydmljZSIsImVhdF9ub25jZSI6IjkxNmNlMzI3ZjU3ZGI1YzhlZTVhZWIwMGM4NjE5M2ZjMDFkOWZmODU1NzhkNzZmMDdmNzQ4ZjkzOWI4NTJhODIiLCJlYXRfcHJvZmlsZSI6Imh0dHBzOi8vY2xvdWQuZ29vZ2xlLmNvbS9jb25maWRlbnRpYWwtY29tcHV0aW5nL2NvbmZpZGVudGlhbC1zcGFjZS9kb2NzL3JlZmVyZW5jZS90b2tlbi1jbGFpbXMiLCJzZWNib290Ijp0cnVlLCJvZW1pZCI6MTExMjksImh3bW9kZWwiOiJHQ1BfQU1EX1NFViIsInN3bmFtZSI6IkNPTkZJREVOVElBTF9TUEFDRSIsInN3dmVyc2lvbiI6WyIyNjA3MDEiXSwiZGJnc3RhdCI6ImRpc2FibGVkLXNpbmNlLWJvb3QiLCJzdWJtb2RzIjp7ImNvbmZpZGVudGlhbF9zcGFjZSI6eyJzdXBwb3J0X2F0dHJpYnV0ZXMiOlsiTEFURVNUIiwiU1RBQkxFIiwiVVNBQkxFIl0sIm1vbml0b3JpbmdfZW5hYmxlZCI6eyJtZW1vcnkiOmZhbHNlfX0sImNvbnRhaW5lciI6eyJpbWFnZV9yZWZlcmVuY2UiOiJ1cy1jZW50cmFsMS1kb2NrZXIucGtnLmRldi90ZW5iaW4tcHJvZC92ZXJpZmljYXRpb24tc2VydmljZS92ZXJpZmljYXRpb24tc2VydmljZTptYWluLTBhNzZiMGRAc2hhMjU2OmI1NzRjNGZkNDEyYTVkN2QyOTliYWU0ZjJhM2E1YWY1ZTE4NWZkNGE0OTY1YzgyMDA4NzBlNzNhYmVhYjhlZTQiLCJpbWFnZV9kaWdlc3QiOiJzaGEyNTY6YjU3NGM0ZmQ0MTJhNWQ3ZDI5OWJhZTRmMmEzYTVhZjVlMTg1ZmQ0YTQ5NjVjODIwMDg3MGU3M2FiZWFiOGVlNCIsInJlc3RhcnRfcG9saWN5IjoiQWx3YXlzIiwiaW1hZ2VfaWQiOiJzaGEyNTY6YmRmMDhlMWNiOWE4ODNjZWM5MTNjZTcyMjgxOGQ3OGFiOGNkZWJjZjVmY2NlY2IyNDQ2OTNkZGNmYTViZGE5YSIsImVudl9vdmVycmlkZSI6eyJBVFRFU1RBVElPTl9fQVVESUVOQ0UiOiJodHRwczovL3B1YmxpYy1hcGkudGVuYmluLnh5ei92MS92ZXJpZmllciIsIkhJRERFTl9ST0FEX0JSTF9DTElFTlRfSURfUkVTT1VSQ0UiOiJwcm9qZWN0cy90ZW5iaW4tcHJvZC9zZWNyZXRzL3RlbmJpbi1wcm9kLXBsYXRmb3JtLXZlcmlmaWNhdGlvbi1zZXJ2aWNlLWhpZGRlbi1yb2FkLWJybC1jbGllbnQtaWQiLCJISURERU5fUk9BRF9CUkxfQ0xJRU5UX1NFQ1JFVF9SRVNPVVJDRSI6InByb2plY3RzL3RlbmJpbi1wcm9kL3NlY3JldHMvdGVuYmluLXByb2QtcGxhdGZvcm0tdmVyaWZpY2F0aW9uLXNlcnZpY2UtaGlkZGVuLXJvYWQtYnJsLWNsaWVudC1zZWNyZXQiLCJISURERU5fUk9BRF9HT0xEX0NMSUVOVF9JRF9SRVNPVVJDRSI6InByb2plY3RzL3RlbmJpbi1wcm9kL3NlY3JldHMvdGVuYmluLXByb2QtcGxhdGZvcm0tdmVyaWZpY2F0aW9uLXNlcnZpY2UtaGlkZGVuLXJvYWQtZ29sZC1jbGllbnQtaWQiLCJISURERU5fUk9BRF9HT0xEX0NMSUVOVF9TRUNSRVRfUkVTT1VSQ0UiOiJwcm9qZWN0cy90ZW5iaW4tcHJvZC9zZWNyZXRzL3RlbmJpbi1wcm9kLXBsYXRmb3JtLXZlcmlmaWNhdGlvbi1zZXJ2aWNlLWhpZGRlbi1yb2FkLWdvbGQtY2xpZW50LXNlY3JldCIsIkhJRERFTl9ST0FEX01YTl9DTElFTlRfSURfUkVTT1VSQ0UiOiJwcm9qZWN0cy90ZW5iaW4tcHJvZC9zZWNyZXRzL3RlbmJpbi1wcm9kLXBsYXRmb3JtLXZlcmlmaWNhdGlvbi1zZXJ2aWNlLWhpZGRlbi1yb2FkLW14bi1jbGllbnQtaWQiLCJISURERU5fUk9BRF9NWE5fQ0xJRU5UX1NFQ1JFVF9SRVNPVVJDRSI6InByb2plY3RzL3RlbmJpbi1wcm9kL3NlY3JldHMvdGVuYmluLXByb2QtcGxhdGZvcm0tdmVyaWZpY2F0aW9uLXNlcnZpY2UtaGlkZGVuLXJvYWQtbXhuLWNsaWVudC1zZWNyZXQiLCJSUENfX0hUVFBfVVJMIjoiaHR0cDovLzEwLjQwLjE1LjIxNjo4NTQ1IiwiUlVTVF9MT0ciOiJpbmZvIn0sImVudiI6eyJBVFRFU1RBVElPTl9fQVVESUVOQ0UiOiJodHRwczovL3B1YmxpYy1hcGkudGVuYmluLnh5ei92MS92ZXJpZmllciIsIkhJRERFTl9ST0FEX0JSTF9DTElFTlRfSURfUkVTT1VSQ0UiOiJwcm9qZWN0cy90ZW5iaW4tcHJvZC9zZWNyZXRzL3RlbmJpbi1wcm9kLXBsYXRmb3JtLXZlcmlmaWNhdGlvbi1zZXJ2aWNlLWhpZGRlbi1yb2FkLWJybC1jbGllbnQtaWQiLCJISURERU5fUk9BRF9CUkxfQ0xJRU5UX1NFQ1JFVF9SRVNPVVJDRSI6InByb2plY3RzL3RlbmJpbi1wcm9kL3NlY3JldHMvdGVuYmluLXByb2QtcGxhdGZvcm0tdmVyaWZpY2F0aW9uLXNlcnZpY2UtaGlkZGVuLXJvYWQtYnJsLWNsaWVudC1zZWNyZXQiLCJISURERU5fUk9BRF9HT0xEX0NMSUVOVF9JRF9SRVNPVVJDRSI6InByb2plY3RzL3RlbmJpbi1wcm9kL3NlY3JldHMvdGVuYmluLXByb2QtcGxhdGZvcm0tdmVyaWZpY2F0aW9uLXNlcnZpY2UtaGlkZGVuLXJvYWQtZ29sZC1jbGllbnQtaWQiLCJISURERU5fUk9BRF9HT0xEX0NMSUVOVF9TRUNSRVRfUkVTT1VSQ0UiOiJwcm9qZWN0cy90ZW5iaW4tcHJvZC9zZWNyZXRzL3RlbmJpbi1wcm9kLXBsYXRmb3JtLXZlcmlmaWNhdGlvbi1zZXJ2aWNlLWhpZGRlbi1yb2FkLWdvbGQtY2xpZW50LXNlY3JldCIsIkhJRERFTl9ST0FEX01YTl9DTElFTlRfSURfUkVTT1VSQ0UiOiJwcm9qZWN0cy90ZW5iaW4tcHJvZC9zZWNyZXRzL3RlbmJpbi1wcm9kLXBsYXRmb3JtLXZlcmlmaWNhdGlvbi1zZXJ2aWNlLWhpZGRlbi1yb2FkLW14bi1jbGllbnQtaWQiLCJISURERU5fUk9BRF9NWE5fQ0xJRU5UX1NFQ1JFVF9SRVNPVVJDRSI6InByb2plY3RzL3RlbmJpbi1wcm9kL3NlY3JldHMvdGVuYmluLXByb2QtcGxhdGZvcm0tdmVyaWZpY2F0aW9uLXNlcnZpY2UtaGlkZGVuLXJvYWQtbXhuLWNsaWVudC1zZWNyZXQiLCJIT1NUTkFNRSI6InRlbmJpbi1wcm9kLXZlcmlmaWNhdGlvbi1zZXJ2aWNlIiwiUEFUSCI6Ii91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsIlJQQ19fSFRUUF9VUkwiOiJodHRwOi8vMTAuNDAuMTUuMjE2Ojg1NDUiLCJSVVNUX0xPRyI6ImluZm8ifSwiYXJncyI6WyJ2ZXJpZmljYXRpb24tc2VydmljZSIsIi0tY29uZmlnIiwiL2V0Yy92ZXJpZmljYXRpb24tc2VydmljZS92ZXJpZmljYXRpb24udG9tbCJdfSwiZ2NlIjp7InpvbmUiOiJ1cy1jZW50cmFsMS1hIiwicHJvamVjdF9pZCI6InRlbmJpbi1wcm9kIiwicHJvamVjdF9udW1iZXIiOiI5MDY4OTY1ODY2MDciLCJpbnN0YW5jZV9uYW1lIjoidGVuYmluLXByb2QtdmVyaWZpY2F0aW9uLXNlcnZpY2UiLCJpbnN0YW5jZV9pZCI6IjM0MTc5MTEzMjA1MDA1MDczNzMifX0sImdvb2dsZV9zZXJ2aWNlX2FjY291bnRzIjpbInRiLXByb2QtdmVyaWZpY2F0aW9uQHRlbmJpbi1wcm9kLmlhbS5nc2VydmljZWFjY291bnQuY29tIl19.GqXbVrLhawU-EnmAkQQtXGVlN6fjk6SZjCAxL4to4LTVopM9_TzjzphTM867hjxMJ3EWCN0XrQT0v4RBO6JdSKoFEWtFYih1l9eEsb6-U8e-7uStMH1TJweZWCrCAeX2b0CgW-4XqcmqCsFNdAashgYZYdoIZzEgU8jbd6N7Ik2GBgu5s6ajKDOOrY-pP8Rsaawc8YNOnsOPS0K408eEl1qo2V-kR1HAwTXlqQ_2GHllk5bpFIifxpF5aFbVwsnnIiIJ72ONIls5rpCv-GDCeSHC0R0Ay-IGEbJY7ijxG5p3KbcdgxVoeVJACmGpAd7Nn2yX00NUmqU13Bn8496Tcw", + "audience": "https://public-api.tenbin.xyz/v1/verifier", + "token_type": "PKI" + } +} diff --git a/packages/sources/tenbin/test/unit/github-build-2026-09-03.json b/packages/sources/tenbin/test/unit/github-build-2026-09-03.json new file mode 100644 index 0000000000..525572c14e --- /dev/null +++ b/packages/sources/tenbin/test/unit/github-build-2026-09-03.json @@ -0,0 +1,9 @@ +{ + "release_tag": "deploy-0a76b0d", + "source_repo": "tenbinlabs/verification-service", + "commit": "0a76b0d229eb9be9ff933ffb95a0c5785fbd5c9e", + "short_commit": "0a76b0d", + "image": "us-central1-docker.pkg.dev/tenbin-prod/verification-service/verification-service:main-0a76b0d@sha256:b574c4fd412a5d7d299bae4f2a3a5af5e185fd4a4965c8200870e73abeab8ee4", + "image_digest": "sha256:b574c4fd412a5d7d299bae4f2a3a5af5e185fd4a4965c8200870e73abeab8ee4", + "endpoint": "https://public-api.tenbin.xyz/v1/verifier" +} diff --git a/packages/sources/tenbin/test/unit/util.test.ts b/packages/sources/tenbin/test/unit/util.test.ts new file mode 100644 index 0000000000..d907a71c03 --- /dev/null +++ b/packages/sources/tenbin/test/unit/util.test.ts @@ -0,0 +1,385 @@ +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' +import { + PINNED, + verifyAttestResponse, + verifyDeepEquals, + verifyJwtBodyEatsNonce, +} from '../../src/transport/util' +import { AttestResponse } from '../../src/transport/verified-balance' + +const originalPinned: typeof PINNED = { ...PINNED } + +describe('util', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-01T14:00:00Z')) + + for (const key of Object.keys(originalPinned) as (keyof typeof PINNED)[]) { + PINNED[key] = originalPinned[key] + } + }) + + describe('verifyDeepEquals', () => { + it('should succeed with equal objects', () => { + const obj1 = { a: 1, b: { c: 2 } } + const obj2 = { a: 1, b: { c: 2 } } + expect(() => { + verifyDeepEquals({ obj1, obj2 }) + }).not.toThrow() + }) + + it('should fail with unequal objects', () => { + const obj1 = { a: 1, b: { c: 2 } } + const obj2 = { a: 1, b: { c: 3 } } + expect(() => { + verifyDeepEquals({ obj1, obj2 }) + }).toThrow('Mismatch at obj1.b.c is 2; obj2.b.c is 3') + }) + + it('should fail with objects with different keys', () => { + const obj1 = { a: 1, b: { c: 2 } } + const obj2 = { a: 1, b: { d: 2 } } + expect(() => { + verifyDeepEquals({ obj1, obj2 }) + }).toThrow('Mismatch at keys(obj1.b)[0] is "c"; keys(obj2.b)[0] is "d"') + }) + + it('should fail with objects with different number of keys', () => { + const obj1 = { a: 1, b: { c: 2 } } + const obj2 = { a: 1, b: { c: 2, d: 3 } } + expect(() => { + verifyDeepEquals({ obj1, obj2 }) + }).toThrow('Mismatch at keys(obj1.b).length is 1; keys(obj2.b).length is 2') + }) + + it('should fail with values with different types', () => { + const obj1 = { a: 1, b: { c: 2 } } + const obj2 = { a: 1, b: { c: '2' } } + expect(() => { + verifyDeepEquals({ obj1, obj2 }) + }).toThrow('Mismatch at type of obj1.b.c is "number"; type of obj2.b.c is "string"') + }) + + it('should fail with nullable values with different types', () => { + const obj1 = { a: 1, b: { c: null } } + const obj2 = { a: 1, b: { c: undefined } } + expect(() => { + verifyDeepEquals({ obj1, obj2 }) + }).toThrow('Mismatch at type of obj1.b.c is "null"; type of obj2.b.c is "undefined"') + }) + + it('should fail with array vs object', () => { + const obj1 = { '0': 1, '1': 2 } + const array2 = [1, 2] + expect(() => { + verifyDeepEquals({ obj1, array2 }) + }).toThrow('Mismatch at type of obj1 is "object"; type of array2 is "array"') + }) + + it('should fail with arrays of different length', () => { + const array1 = [1, 2] + const array2 = [1, 2, 3] + expect(() => { + verifyDeepEquals({ array1, array2 }) + }).toThrow('Mismatch at array1.length is 2; array2.length is 3') + }) + + it('should fail with arrays with different values', () => { + const array1 = [1, 2] + const array2 = [1, 3] + expect(() => { + verifyDeepEquals({ array1, array2 }) + }).toThrow('Mismatch at array1[1] is 2; array2[1] is 3') + }) + + it('should fail with arrays with different object values', () => { + const array1 = [1, { a: 2 }] + const array2 = [1, { a: 3 }] + expect(() => { + verifyDeepEquals({ array1, array2 }) + }).toThrow('Mismatch at array1[1].a is 2; array2[1].a is 3') + }) + }) + + describe('verifyAttestResponse', () => { + const responseJson = fs.readFileSync(path.join(__dirname, 'attest-response-2026-09-01.json')) + const buildJson = fs.readFileSync(path.join(__dirname, 'github-build-2026-09-03.json')) + const buildResponse = JSON.parse(buildJson.toString()) + + it('should succeed with correct attest response', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).not.toThrow() + }) + + it('should fail when canonical is not valid JSON', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const jsonLength = attestResponse.canonical.length + attestResponse.canonical += '{}' + + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + `Failed to parse canonical data: SyntaxError: Unexpected non-whitespace character after JSON at position ${jsonLength} (line 1 column ${ + jsonLength + 1 + })`, + ) + }) + + it('should fail with modified attest response', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const originalBalance = attestResponse.data.agg_usd_computed_balance + attestResponse.data.agg_usd_computed_balance = '0' + + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + `Mismatch at canonical.agg_usd_computed_balance is "${originalBalance}"; data.agg_usd_computed_balance is "0"`, + ) + }) + + it('should fail with modified nonce', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const originalNonce = attestResponse.nonce + attestResponse.nonce += '0' + + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + `Mismatch at canonicalHash is "${originalNonce}"; nonce is "${attestResponse.nonce}"`, + ) + }) + + it('should fail with unexpected alg in JWT', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const jwtParts = attestResponse.attestation.jwt.split('.') + const jwtHeader = JSON.parse(Buffer.from(jwtParts[0], 'base64').toString('utf-8')) + + jwtHeader.alg = 'ES256' + + jwtParts[0] = Buffer.from(JSON.stringify(jwtHeader)).toString('base64') + attestResponse.attestation.jwt = jwtParts.join('.') + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow(`Mismatch at headerAlg is "${jwtHeader.alg}"; expectedAlg is "RS256"`) + }) + + it('should fail certificate chain in wrong order', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const jwtParts = attestResponse.attestation.jwt.split('.') + const jwtHeader = JSON.parse(Buffer.from(jwtParts[0], 'base64').toString('utf-8')) + + jwtHeader.x5c = jwtHeader.x5c.reverse() + + jwtParts[0] = Buffer.from(JSON.stringify(jwtHeader)).toString('base64') + attestResponse.attestation.jwt = jwtParts.join('.') + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + 'Certificate chain is broken at index 0: issuer does not match subject of next certificate', + ) + }) + + it('should fail certificates are not valid yet', () => { + jest.setSystemTime(new Date('2020-01-01T12:00:00Z')) + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow('Certificate is not yet valid: validFrom is Aug 31 14:28:54 2026 GMT') + }) + + it('should fail certificates are expired', () => { + jest.setSystemTime(new Date('2027-01-01T12:00:00Z')) + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow('Certificate has expired: validTo is Oct 30 14:28:53 2026 GMT') + }) + + it('should use the expected pinned root certificate', () => { + const cert = new crypto.X509Certificate(PINNED.ROOT_CERTIFICATE) + expect(cert.fingerprint256.replace(/:/g, '').toLowerCase()).toBe( + '148b293821bb0c6a317f413c8ba475814091cb22d49b9e3c94198db8e8f86c39', + ) + }) + + it('should fail signature verification if the header is modified', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const jwtParts = attestResponse.attestation.jwt.split('.') + const jwtHeader = JSON.parse(Buffer.from(jwtParts[0], 'base64').toString('utf-8')) + + jwtHeader.unused = 'test' + + jwtParts[0] = Buffer.from(JSON.stringify(jwtHeader)).toString('base64') + attestResponse.attestation.jwt = jwtParts.join('.') + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow('Signature verification failed') + }) + + it('should fail if iss does not match pinned value', () => { + PINNED.ISS = 'different' + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow(`Mismatch at iss is "${originalPinned.ISS}"; pinnedIss is "${PINNED.ISS}"`) + }) + + it('should fail if aud does not match pinned value', () => { + PINNED.AUD = 'different' + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow(`Mismatch at aud is "${originalPinned.AUD}"; pinnedAud is "${PINNED.AUD}"`) + }) + + it('should fail if exp is not in the future', () => { + jest.setSystemTime(new Date('2026-10-01T12:00:00Z')) + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow('JWT has expired: exp is 1788272248, now is 1790856000') + }) + + it('should fail if nbf is not in the past', () => { + jest.setSystemTime(new Date('2026-09-01T12:00:00Z')) + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow('JWT is not yet valid: nbf is 1788268648, now is 1788264000') + }) + + it('should fail if swname does not match pinned value', () => { + PINNED.SWNAME = 'different' + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + `Mismatch at swname is "${originalPinned.SWNAME}"; pinnedSwname is "${PINNED.SWNAME}"`, + ) + }) + + it('should fail if dbgstat does not match pinned value', () => { + PINNED.DBGSTAT = 'different' + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + `Mismatch at dbgstat is "${originalPinned.DBGSTAT}"; pinnedDbgstat is "${PINNED.DBGSTAT}"`, + ) + }) + + it('should fail if jwt does not eat nonce', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + // Change the data to get a different nonce than the one eaten by the JWT + attestResponse.data.agg_usd_computed_balance = '123' + attestResponse.canonical = JSON.stringify(attestResponse.data) + attestResponse.nonce = crypto + .createHash('sha256') + .update(attestResponse.canonical) + .digest('hex') + + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + `JWT body does not eat the nonce: ${attestResponse.nonce}. Eaten: 916ce327f57db5c8ee5aeb00c86193fc01d9ff85578d76f07f748f939b852a82`, + ) + }) + + it('should fail if digest does not match build.json', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const buildResponse = JSON.parse(buildJson.toString()) + + buildResponse.image_digest = 'different' + + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow( + `Mismatch at imageDigest is "sha256:b574c4fd412a5d7d299bae4f2a3a5af5e185fd4a4965c8200870e73abeab8ee4"; buildJsonImageDigest is "${buildResponse.image_digest}"`, + ) + }) + + it('should fail if x5c contains non-string entries', () => { + const attestResponse = JSON.parse(responseJson.toString()) as AttestResponse + const jwtParts = attestResponse.attestation.jwt.split('.') + const jwtHeader = JSON.parse(Buffer.from(jwtParts[0], 'base64').toString('utf-8')) + + jwtHeader.x5c[0] = 123 + + jwtParts[0] = Buffer.from(JSON.stringify(jwtHeader)).toString('base64') + attestResponse.attestation.jwt = jwtParts.join('.') + expect(() => { + verifyAttestResponse(attestResponse, buildResponse) + }).toThrow('Invalid x5c: all entries must be strings') + }) + }) + + describe('verifyJwtBodyEatsNonce', () => { + it('should succeed when eat_nonce is a string that matches nonce', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: nonce } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).not.toThrow() + }) + + it('should succeed when eat_nonce is a string that matches nonce case-insensitively', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: 'TEST-NONCE' } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).not.toThrow() + }) + + it('should succeed when eat_nonce is an array containing matching nonce', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: ['other-nonce', nonce, 'another-nonce'] } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).not.toThrow() + }) + + it('should fail when eat_nonce is null', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: null } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).toThrow(`JWT body does not eat the nonce: ${nonce}. Eaten: `) + }) + + it('should fail when eat_nonce is undefined', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: undefined } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).toThrow(`JWT body does not eat the nonce: ${nonce}. Eaten: `) + }) + + it('should skip non-string entries and match string entries in array', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: [123, nonce, null] } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).not.toThrow() + }) + + it('should fail when eat_nonce array contains no matching string entries', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: [123, 'invalid-nonce', null] } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).toThrow(`JWT body does not eat the nonce: ${nonce}. Eaten: 123, invalid-nonce, `) + }) + + it('should fail when eat_nonce does not match nonce', () => { + const nonce = 'test-nonce' + const body = { eat_nonce: 'different-nonce' } + expect(() => { + verifyJwtBodyEatsNonce(body, nonce) + }).toThrow(`JWT body does not eat the nonce: ${nonce}. Eaten: different-nonce`) + }) + }) +}) diff --git a/packages/sources/tenbin/test/unit/verified-balance.test.ts b/packages/sources/tenbin/test/unit/verified-balance.test.ts new file mode 100644 index 0000000000..92bab574bc --- /dev/null +++ b/packages/sources/tenbin/test/unit/verified-balance.test.ts @@ -0,0 +1,282 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { deferredPromise, LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' +import { BaseEndpointTypes } from '../../src/endpoint/verified-balance' +import { verifyAttestResponse } from '../../src/transport/util' +import { VerifiedBalanceTransport } from '../../src/transport/verified-balance' + +jest.mock('../../src/transport/util', () => { + return { + verifyAttestResponse: jest.fn(), + } +}) + +const log = jest.fn() +const debugLog = jest.fn() +const logger = { + fatal: log, + error: log, + warn: log, + info: log, + debug: debugLog, + trace: debugLog, + msgPrefix: 'mock-logger', +} + +const loggerFactory = { child: () => logger } + +LoggerFactoryProvider.set(loggerFactory) + +describe('VerifiedBalanceTransport', () => { + const transportName = 'default_single_transport' + const endpointName = 'verified-balance' + const API_ENDPOINT = 'http://api.example.com' + const API_KEY = 'test-api-key' + const BUILD_JSON_ENDPOINT = 'http://build-json.example.com' + const BACKGROUND_EXECUTE_MS = 10_000 + + const adapterSettings = makeStub('adapterSettings', { + API_ENDPOINT, + API_KEY, + BUILD_JSON_ENDPOINT, + WARMUP_SUBSCRIPTION_TTL: 10_000, + BACKGROUND_EXECUTE_MS, + MAX_COMMON_KEY_SIZE: 300, + } as unknown as BaseEndpointTypes['Settings']) + + const context = makeStub('context', { + adapterSettings, + } as EndpointContext) + + const requester = makeStub('requester', { + request: jest.fn(), + }) + + const responseCache = { + write: jest.fn(), + } + + const dependencies = makeStub('dependencies', { + requester, + responseCache, + subscriptionSetFactory: { + buildSet: jest.fn(), + }, + } as unknown as TransportDependencies) + + let transport: VerifiedBalanceTransport + + type RequestConfig = { + baseURL: string + method: 'GET' + headers?: Record + } + + const requestConfigForAttest = { + method: 'GET', + baseURL: adapterSettings.API_ENDPOINT, + headers: { + Authorization: `Bearer ${adapterSettings.API_KEY}`, + }, + } as const + + const requestConfigForBuildJson = { + method: 'GET', + baseURL: adapterSettings.BUILD_JSON_ENDPOINT, + } as const + + const requestKeyForConfig = (requestConfig: RequestConfig) => { + return requestConfig.baseURL + } + + const mockAttestResponse = (balance: string | Promise) => { + requester.request.mockImplementationOnce(async () => { + return { + response: { + data: { + data: { + agg_usd_computed_balance: await balance, + }, + }, + }, + } + }) + } + + const mockBuildJsonResponse = () => { + requester.request.mockImplementationOnce(async () => { + return { + response: { + data: { + // Stub response is not used. + }, + }, + } + }) + } + + beforeEach(async () => { + jest.resetAllMocks() + jest.useFakeTimers() + + transport = new VerifiedBalanceTransport() + + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + }) + + afterEach(() => { + expect(log).not.toBeCalled() + }) + + describe('backgroundHandler', () => { + it('should sleep after handleRequest', async () => { + const t0 = Date.now() + let t1 = 0 + transport.backgroundHandler(context, []).then(() => { + t1 = Date.now() + }) + await jest.runAllTimersAsync() + expect(t1 - t0).toBe(BACKGROUND_EXECUTE_MS) + }) + }) + + describe('handleRequest', () => { + it('should cache a successful response', async () => { + const balance = '1000' + const params = makeStub('params', {}) + + mockAttestResponse(balance) + mockBuildJsonResponse() + + await transport.handleRequest(params) + + const expectedResult = balance + + const expectedResponse = { + statusCode: 200, + result: expectedResult, + data: { + result: expectedResult, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + + expect(responseCache.write).toBeCalledWith(transportName, [ + { + params, + response: expectedResponse, + }, + ]) + expect(responseCache.write).toBeCalledTimes(1) + }) + + it('should cache an error response', async () => { + const params = makeStub('params', {}) + const balance = '1000' + const errorMessage = 'verifyAttestResponse error' + + mockAttestResponse(balance) + mockBuildJsonResponse() + + jest.mocked(verifyAttestResponse).mockImplementationOnce(() => { + throw new Error(errorMessage) + }) + + await transport.handleRequest(params) + + const expectedResponse = { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + + expect(responseCache.write).toBeCalledWith(transportName, [ + { + params, + response: expectedResponse, + }, + ]) + expect(responseCache.write).toBeCalledTimes(1) + + expect(log).toBeCalledWith(expect.any(Error), errorMessage) + log.mockClear() + }) + }) + + describe('_handleRequest', () => { + it('should return balance response', async () => { + const balance = '2100' + const params = makeStub('params', {}) + + mockAttestResponse(balance) + mockBuildJsonResponse() + + const response = await transport._handleRequest(params) + + const expectedResult = balance + + expect(response).toEqual({ + statusCode: 200, + result: expectedResult, + data: { + result: expectedResult, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + }) + + expect(requester.request).toHaveBeenCalledWith( + requestKeyForConfig(requestConfigForAttest), + requestConfigForAttest, + ) + expect(requester.request).toHaveBeenCalledWith( + requestKeyForConfig(requestConfigForBuildJson), + requestConfigForBuildJson, + ) + expect(requester.request).toHaveBeenCalledTimes(2) + }) + + it('should record received timestamp separate from requested timestamp', async () => { + const balance = '2100' + const params = makeStub('params', {}) + + const [balancePromise, resolveBalance] = deferredPromise() + mockAttestResponse(balancePromise) + mockBuildJsonResponse() + + const requestTimestamp = Date.now() + const responsePromise = transport._handleRequest(params) + jest.advanceTimersByTime(1234) + const responseTimestamp = Date.now() + expect(responseTimestamp).toBeGreaterThan(requestTimestamp) + + resolveBalance(balance) + + const expectedResult = balance + expect(await responsePromise).toEqual({ + statusCode: 200, + result: expectedResult, + data: { + result: expectedResult, + }, + timestamps: { + providerDataRequestedUnixMs: requestTimestamp, + providerDataReceivedUnixMs: responseTimestamp, + providerIndicatedTimeUnixMs: undefined, + }, + }) + }) + }) +}) diff --git a/packages/sources/tenbin/tsconfig.json b/packages/sources/tenbin/tsconfig.json new file mode 100644 index 0000000000..377749c896 --- /dev/null +++ b/packages/sources/tenbin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/sources/tenbin/tsconfig.test.json b/packages/sources/tenbin/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/tenbin/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 1153fc5634..6f9891d3ae 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -428,6 +428,9 @@ { "path": "./sources/t-rize-proof-of-insurance" }, + { + "path": "./sources/tenbin" + }, { "path": "./sources/the-network-firm" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index de07d7aac4..3ca6d1d4e1 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -428,6 +428,9 @@ { "path": "./sources/t-rize-proof-of-insurance/tsconfig.test.json" }, + { + "path": "./sources/tenbin/tsconfig.test.json" + }, { "path": "./sources/the-network-firm/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 998bfd6f3c..aa32887c9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4671,6 +4671,19 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/tenbin-adapter@workspace:packages/sources/tenbin": + version: 0.0.0-use.local + resolution: "@chainlink/tenbin-adapter@workspace:packages/sources/tenbin" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.19.1" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/the-network-firm-adapter@workspace:packages/sources/the-network-firm": version: 0.0.0-use.local resolution: "@chainlink/the-network-firm-adapter@workspace:packages/sources/the-network-firm"