diff --git a/.changeset/thirty-ducks-wait.md b/.changeset/thirty-ducks-wait.md new file mode 100644 index 0000000000..d0116096c7 --- /dev/null +++ b/.changeset/thirty-ducks-wait.md @@ -0,0 +1,5 @@ +--- +'@chainlink/liveart-adapter': major +--- + +LiveArt EA initial release diff --git a/.pnp.cjs b/.pnp.cjs index 447cba88bd..3d76228309 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -654,6 +654,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/lition-adapter",\ "reference": "workspace:packages/sources/lition"\ },\ + {\ + "name": "@chainlink/liveart-adapter",\ + "reference": "workspace:packages/sources/liveart"\ + },\ {\ "name": "@chainlink/lotus-adapter",\ "reference": "workspace:packages/sources/lotus"\ @@ -1114,6 +1118,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/linear-finance-adapter", ["workspace:packages/composites/linear-finance"]],\ ["@chainlink/linkpool-adapter", ["workspace:packages/sources/linkpool"]],\ ["@chainlink/lition-adapter", ["workspace:packages/sources/lition"]],\ + ["@chainlink/liveart-adapter", ["workspace:packages/sources/liveart"]],\ ["@chainlink/llama-guard-adapter", ["workspace:packages/composites/llama-guard"]],\ ["@chainlink/lotus-adapter", ["workspace:packages/sources/lotus"]],\ ["@chainlink/m0-adapter", ["workspace:packages/sources/m0"]],\ @@ -7791,6 +7796,21 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/liveart-adapter", [\ + ["workspace:packages/sources/liveart", {\ + "packageLocation": "./packages/sources/liveart/",\ + "packageDependencies": [\ + ["@chainlink/liveart-adapter", "workspace:packages/sources/liveart"],\ + ["@chainlink/external-adapter-framework", "npm:2.8.0"],\ + ["@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/llama-guard-adapter", [\ ["workspace:packages/composites/llama-guard", {\ "packageLocation": "./packages/composites/llama-guard/",\ diff --git a/packages/sources/liveart/package.json b/packages/sources/liveart/package.json new file mode 100644 index 0000000000..b333579763 --- /dev/null +++ b/packages/sources/liveart/package.json @@ -0,0 +1,34 @@ +{ + "name": "@chainlink/liveart-adapter", + "version": "1.0.0", + "description": "LiveArt NAV external adapter for Chainlink", + "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": { + "test": "echo \"Error: no test specified\" && exit 1", + "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", + "prepack": "yarn build", + "build": "tsc -b", + "server": "node -e 'require(\"./src/index.ts\").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.8.0", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/liveart/src/config/config.ts b/packages/sources/liveart/src/config/config.ts new file mode 100644 index 0000000000..51486cc797 --- /dev/null +++ b/packages/sources/liveart/src/config/config.ts @@ -0,0 +1,9 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config: AdapterConfig = new AdapterConfig({ + API_BASE_URL: { + description: 'The API URL for the LiveArt data provider', + type: 'string', + required: true, + }, +}) diff --git a/packages/sources/liveart/src/endpoint/nav.ts b/packages/sources/liveart/src/endpoint/nav.ts new file mode 100644 index 0000000000..245f032a0a --- /dev/null +++ b/packages/sources/liveart/src/endpoint/nav.ts @@ -0,0 +1,25 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' + +import { httpTransport } from '../transport/nav' + +export const inputParameters = new InputParameters( + { + asset_id: { + required: true, + type: 'string', + description: 'The ID of the artwork asset to fetch', + }, + }, + [ + { + asset_id: 'KUSPUM', + }, + ], +) + +export const nav = new AdapterEndpoint({ + name: 'nav', + transport: httpTransport, + inputParameters, +}) diff --git a/packages/sources/liveart/src/index.ts b/packages/sources/liveart/src/index.ts new file mode 100644 index 0000000000..71b880adb5 --- /dev/null +++ b/packages/sources/liveart/src/index.ts @@ -0,0 +1,14 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' + +import { config } from './config/config' +import { nav } from './endpoint/nav' + +export const adapter = new Adapter({ + defaultEndpoint: nav.name, + name: 'LIVE_ART', + config, + endpoints: [nav], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/liveart/src/transport/nav.ts b/packages/sources/liveart/src/transport/nav.ts new file mode 100644 index 0000000000..8b70ebae17 --- /dev/null +++ b/packages/sources/liveart/src/transport/nav.ts @@ -0,0 +1,86 @@ +import { HttpTransport } from '@chainlink/external-adapter-framework/transports/http' +import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util' +import { TypeFromDefinition } from '@chainlink/external-adapter-framework/validation/input-params' +import { config } from '../config/config' +import { inputParameters } from '../endpoint/nav' + +export interface ResponseSchema { + asset_id: string + asset_info_category: string + asset_info_creator: string + asset_info_title: string + asset_info_year_created: string + asset_info_description: string + asset_info_url: string + current_estimated_nav_usd: string + current_estimated_nav_updated_at: string + token_total_shares: number + token_current_estimated_nav_per_share_usd: string + offering_price_usd: string + success: boolean + message: string + response_timestamp: string +} + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: SingleNumberResultResponse + Settings: typeof config.settings +} + +export type HttpTransportTypes = BaseEndpointTypes & { + Provider: { + RequestBody: never + ResponseBody: ResponseSchema + } +} + +export const httpTransport = new HttpTransport({ + prepareRequests: (params, adapterSettings) => { + return params.map((param) => { + return { + params: [param], + request: { + baseURL: adapterSettings.API_BASE_URL, + url: `/asset/${param.asset_id}`, + }, + } + }) + }, + parseResponse: (params, response) => { + return params.map((param: TypeFromDefinition) => { + if (param.asset_id !== response.data.asset_id) { + return { + params: param, + response: { + errorMessage: `Mismatched asset_id in response. Expected ${param.asset_id}, got ${response.data.asset_id}`, + statusCode: 502, + }, + } + } + const responseData = response.data + + if (!responseData.success) + return { + params: param, + response: { + errorMessage: responseData.message, + statusCode: 502, + }, + } + + const navString = responseData.token_current_estimated_nav_per_share_usd + const nav = parseFloat(navString) + + return { + params: param, + response: { + result: nav, + data: { + result: nav, + }, + }, + } + }) + }, +}) diff --git a/packages/sources/liveart/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/liveart/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..9b36a79c12 --- /dev/null +++ b/packages/sources/liveart/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LiveArt NAV endpoints nav should handle upstream bad response for unsuccessful request 1`] = ` +{ + "errorMessage": "Asset ID 'AssetId.ROLSUB' not found", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`LiveArt NAV endpoints nav should return error for invalid asset_id 1`] = ` +{ + "errorMessage": "Mismatched asset_id in response. Expected invalid-asset-id, got KUSPUM", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`LiveArt NAV endpoints nav should return success for valid asset_id 1`] = ` +{ + "data": { + "result": 0.09630117, + }, + "result": 0.09630117, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; diff --git a/packages/sources/liveart/test/integration/adapter.test.ts b/packages/sources/liveart/test/integration/adapter.test.ts new file mode 100644 index 0000000000..05f3f916f5 --- /dev/null +++ b/packages/sources/liveart/test/integration/adapter.test.ts @@ -0,0 +1,87 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' + +import { nav } from '../../src/endpoint/nav' +import { mockHappyPathResponseSuccessAsset, mockResponseFailureAsset } from './utils/fixtures' +import { TEST_FAILURE_ASSET_ID, TEST_SUCCESS_ASSET_ID, TEST_URL } from './utils/testConfig' +import { clearTestCache } from './utils/utilFunctions' + +describe('LiveArt NAV', () => { + let testAdapter: TestAdapter + let spy: jest.SpyInstance + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + // Mock time for request's timestamp + const mockDate = new Date('2001-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + // Set environment variables + process.env.API_BASE_URL = TEST_URL + + // Create adapter instance only once + const adapter = (await import('../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + clearTestCache(testAdapter) + await testAdapter.api.close() + spy.mockRestore() + setEnvVariables(oldEnv) + nock.restore() + nock.cleanAll() + }) + + describe('endpoints', () => { + describe('nav', () => { + it('should return success for valid asset_id', async () => { + const dataInput = { + asset_id: TEST_SUCCESS_ASSET_ID, + endpoint: nav.name, + } + + mockHappyPathResponseSuccessAsset(dataInput.asset_id) + const response = await testAdapter.request(dataInput) + + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return error for invalid asset_id', async () => { + const data = { + asset_id: 'invalid-asset-id', + endpoint: nav.name, + } + + // Mock for other asset_id + mockHappyPathResponseSuccessAsset(data.asset_id) + + const response = await testAdapter.request(data) + const responseJson = response.json() + expect(responseJson.statusCode).toBe(502) + expect(responseJson).toMatchSnapshot() + }) + + it('should handle upstream bad response for unsuccessful request', async () => { + const data = { + asset_id: TEST_FAILURE_ASSET_ID, + endpoint: nav.name, + } + + mockResponseFailureAsset(data.asset_id) + + const response = await testAdapter.request(data) + const responseJson = response.json() + expect(responseJson.statusCode).toBe(502) + expect(responseJson).toMatchSnapshot() + }) + }) + }) +}) diff --git a/packages/sources/liveart/test/integration/utils/fixtures.ts b/packages/sources/liveart/test/integration/utils/fixtures.ts new file mode 100644 index 0000000000..8158174a8d --- /dev/null +++ b/packages/sources/liveart/test/integration/utils/fixtures.ts @@ -0,0 +1,37 @@ +import nock from 'nock' +import { ErrorResponseAsset, SuccessResponseAsset, TEST_URL } from './testConfig' + +export const mockHappyPathResponseSuccessAsset = (assetId: string): nock.Scope => + nock(TEST_URL) + .get(`/asset/${assetId}`) + .reply(200, () => SuccessResponseAsset, [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ]) + +export const mockResponseFailureAsset = (assetId: string): nock.Scope => + nock(TEST_URL) + .get(`/asset/${assetId}`) + .reply( + 200, + () => ({ + ...ErrorResponseAsset, + asset_id: `${assetId}`, + }), + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) diff --git a/packages/sources/liveart/test/integration/utils/testConfig.ts b/packages/sources/liveart/test/integration/utils/testConfig.ts new file mode 100644 index 0000000000..335ccdcd22 --- /dev/null +++ b/packages/sources/liveart/test/integration/utils/testConfig.ts @@ -0,0 +1,80 @@ +export const TEST_URL = 'https://test.liveart.ai' +export const TEST_SUCCESS_ASSET_ID = 'KUSPUM' +export const TEST_FAILURE_ASSET_ID = 'ROLSUB' + +export const SuccessResponseAsset = { + asset_id: 'KUSPUM', + asset_info_category: 'Artwork', + asset_info_creator: 'Yayoi Kusama', + asset_info_title: 'Pumpkin (2)', + asset_info_year_created: '1990', + asset_info_description: + 'Yayoi Kusama is a living legend whose polka-dotted universe has taken over museums, fashion, and the auction block—generating a $2.4B market cap. From MoMA to Louis Vuitton, her iconic Infinity Rooms and Pumpkin artworks have made her one of the most collected artists alive', + asset_info_url: 'https://liveart.io/analytics/artworks/wDigKX/yayoi-kusama', + current_estimated_nav_usd: '67410.81654052', + current_estimated_nav_updated_at: '2025-10-13T08:50:09.546855', + token_total_shares: 700000, + token_current_estimated_nav_per_share_usd: '0.09630117', + offering_price_usd: '70000.00000000', + success: true, + message: '', + response_timestamp: '2025-10-20T13:01:12.095377', +} + +export const ErrorResponseAsset = { + asset_id: 'ROLSUB', + asset_info_category: '', + asset_info_creator: '', + asset_info_title: '', + asset_info_year_created: '', + asset_info_description: '', + asset_info_url: '', + current_estimated_nav_usd: '', + current_estimated_nav_updated_at: '', + token_total_shares: 0, + token_current_estimated_nav_per_share_usd: '', + offering_price_usd: '', + success: false, + message: "Asset ID 'AssetId.ROLSUB' not found", + response_timestamp: '2025-10-21T11:45:51.122126', +} + +export const SuccessResponseAssets = [ + { + asset_id: 'HARPLA', + asset_info_category: 'Artwork', + asset_info_creator: 'Keith Haring', + asset_info_title: 'Pop Shop IV - Plate II (Radiant Baby)', + asset_info_year_created: '1989', + asset_info_description: + 'Keith Haring is a blue-chip icon with a $644M market cap. His market has an 89% sell-through rate with a price over estimate of 27% . His Pop Shop and Icons series are among his most popular artworks. Collaborations with brands like Uniqlo and Coach keep Haring mainstream while growing his collector base across new demographics. Making him one of the most tradable names in the art market.', + asset_info_url: + 'https://liveart.io/analytics/artworks/z6bjtr/keith-haring/pop-shop-iv-plate-ii-radiant-baby', + current_estimated_nav_usd: '31394.78318046', + current_estimated_nav_updated_at: '2025-10-13T12:40:08.641656', + token_total_shares: 350000, + token_current_estimated_nav_per_share_usd: '0.08969938', + offering_price_usd: '35000.00000000', + success: true, + message: '', + response_timestamp: '2025-10-20T12:37:58.430630', + }, + { + asset_id: 'HOCPOO', + asset_info_category: 'Artwork', + asset_info_creator: 'David Hockney', + asset_info_title: 'Paper Pool', + asset_info_year_created: '1980', + asset_info_description: + 'David Hockney is one of the world’s most valuable living artists, with a $1.9B market cap and a record sale topping $90M.', + asset_info_url: 'https://liveart.io/analytics/artworks/wW9yWc/david-hockney', + current_estimated_nav_usd: '63983.64635652', + current_estimated_nav_updated_at: '2025-10-13T12:40:08.648848', + token_total_shares: 600000, + token_current_estimated_nav_per_share_usd: '0.10663941', + offering_price_usd: '60000.00000000', + success: true, + message: '', + response_timestamp: '2025-10-20T12:37:58.430678', + }, +] diff --git a/packages/sources/liveart/test/integration/utils/utilFunctions.ts b/packages/sources/liveart/test/integration/utils/utilFunctions.ts new file mode 100644 index 0000000000..42f66f0c3a --- /dev/null +++ b/packages/sources/liveart/test/integration/utils/utilFunctions.ts @@ -0,0 +1,12 @@ +import { TestAdapter } from '@chainlink/external-adapter-framework/util/testing-utils' + +export function clearTestCache(testAdapter: TestAdapter) { + // clear EA cache + const keys = testAdapter.mockCache?.cache.keys() + if (!keys) { + throw new Error('unexpected failure 1') + } + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } +} diff --git a/packages/sources/liveart/tsconfig.json b/packages/sources/liveart/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/liveart/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/sources/liveart/tsconfig.test.json b/packages/sources/liveart/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/liveart/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 9f5c873bf7..c36e07eb7f 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -470,6 +470,9 @@ { "path": "./sources/lition" }, + { + "path": "./sources/liveart" + }, { "path": "./sources/lotus" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 3b4caf1a49..9c6185cef2 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -470,6 +470,9 @@ { "path": "./sources/lition/tsconfig.test.json" }, + { + "path": "./sources/liveart/tsconfig.test.json" + }, { "path": "./sources/lotus/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 518e6e3ca6..54b9cb8ce3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4771,6 +4771,19 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/liveart-adapter@workspace:packages/sources/liveart": + version: 0.0.0-use.local + resolution: "@chainlink/liveart-adapter@workspace:packages/sources/liveart" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.8.0" + "@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/llama-guard-adapter@workspace:packages/composites/llama-guard": version: 0.0.0-use.local resolution: "@chainlink/llama-guard-adapter@workspace:packages/composites/llama-guard"