diff --git a/.changeset/six-readers-guess.md b/.changeset/six-readers-guess.md new file mode 100644 index 00000000..040910c9 --- /dev/null +++ b/.changeset/six-readers-guess.md @@ -0,0 +1,5 @@ +--- +"@lucid-evolution/provider": patch +--- + +Add token support to Koios provider diff --git a/packages/provider/src/internal/koios.ts b/packages/provider/src/internal/koios.ts index d45ec1d6..a533a608 100644 --- a/packages/provider/src/internal/koios.ts +++ b/packages/provider/src/internal/koios.ts @@ -268,6 +268,19 @@ export const DatumInfo = S.Struct({ bytes: S.String, }); +export const getHeadersWithToken = ( + token?: string, + headers: Record = {}, +): Record => { + if (token) { + return { + ...headers, + Authorization: `Bearer ${token}`, + }; + } + return headers; +}; + export const postWithSchemaValidation = ( url: string | URL, data: unknown, @@ -295,10 +308,12 @@ export const postWithSchemaValidation = ( export const getWithSchemaValidation = ( url: string | URL, schema: S.Schema, + headers: Record = {}, ) => pipe( - HttpClientRequest.get(url), - HttpClient.fetchOk, + Effect.succeed(HttpClientRequest.get(url)), + Effect.map(HttpClientRequest.setHeaders(headers)), + Effect.flatMap(HttpClient.fetch), HttpClientResponse.json, Effect.flatMap(S.decodeUnknown(schema)), ); @@ -353,6 +368,7 @@ const toScriptRef = ( export const getUtxosEffect = ( baseUrl: string, addressOrCredential: CoreType.Address | CoreType.Credential, + headers: Record = {}, ): Effect.Effect< CoreType.UTxO[], string | HttpClientError | HttpBodyError | ParseError @@ -366,7 +382,7 @@ export const getUtxosEffect = ( Effect.if(typeof addressOrCredential === "string", { onFalse: () => Effect.fail("Credential Type is not supported in Koios yet."), - onTrue: () => postWithSchemaValidation(url, body, schema), + onTrue: () => postWithSchemaValidation(url, body, schema, headers), }), Effect.map(([result]) => result diff --git a/packages/provider/src/koios.ts b/packages/provider/src/koios.ts index ba4dc090..cf22d67f 100644 --- a/packages/provider/src/koios.ts +++ b/packages/provider/src/koios.ts @@ -34,16 +34,22 @@ export class KoiosError extends Data.TaggedError("KoiosError")<{ */ export class Koios implements Provider { private readonly baseUrl: string; + private readonly token?: string; - constructor(baseUrl: string) { + constructor(baseUrl: string, token?: string) { this.baseUrl = baseUrl; + this.token = token; } async getProtocolParameters(): Promise { const url = `${this.baseUrl}/epoch_params?limit=1`; const schema = S.Array(_Koios.ProtocolParametersSchema); const [result] = await pipe( - _Koios.getWithSchemaValidation(url, schema), + _Koios.getWithSchemaValidation( + url, + schema, + _Koios.getHeadersWithToken(this.token), + ), Effect.timeout(10_000), Effect.catchAllCause((cause) => new KoiosError({ cause })), Effect.runPromise, @@ -91,7 +97,11 @@ export class Koios implements Provider { async getUtxos(addressOrCredential: Address | Credential): Promise { const result = await pipe( - _Koios.getUtxosEffect(this.baseUrl, addressOrCredential), + _Koios.getUtxosEffect( + this.baseUrl, + addressOrCredential, + _Koios.getHeadersWithToken(this.token), + ), Effect.timeout(10_000), Effect.catchAllCause((cause) => new KoiosError({ cause })), Effect.runPromise, @@ -104,7 +114,11 @@ export class Koios implements Provider { unit: Unit, ): Promise { const result = await pipe( - _Koios.getUtxosEffect(this.baseUrl, addressOrCredential), + _Koios.getUtxosEffect( + this.baseUrl, + addressOrCredential, + _Koios.getHeadersWithToken(this.token), + ), Effect.map((utxos) => utxos.filter((utxo) => { const keys = Object.keys(utxo.assets); @@ -122,7 +136,11 @@ export class Koios implements Provider { let { policyId, assetName } = fromUnit(unit); const url = `${this.baseUrl}/asset_addresses?_asset_policy=${policyId}&_asset_name=${assetName}`; const result: UTxO = await pipe( - _Koios.getWithSchemaValidation(url, S.Array(_Koios.AssetAddressSchema)), + _Koios.getWithSchemaValidation( + url, + S.Array(_Koios.AssetAddressSchema), + _Koios.getHeadersWithToken(this.token), + ), Effect.flatMap((adresses) => adresses.length === 0 ? Effect.fail("Unit not found") @@ -163,7 +181,12 @@ export class Koios implements Provider { }; const schema = S.Array(_Koios.TxInfoSchema); const [result] = await pipe( - _Koios.postWithSchemaValidation(url, body, schema), + _Koios.postWithSchemaValidation( + url, + body, + schema, + _Koios.getHeadersWithToken(this.token), + ), Effect.timeout(10_000), Effect.catchAllCause((cause) => new KoiosError({ cause })), Effect.runPromise, @@ -209,6 +232,7 @@ export class Koios implements Provider { url, body, S.Array(_Koios.AccountInfoSchema), + _Koios.getHeadersWithToken(this.token), ), Effect.flatMap((result) => result.length === 0 @@ -232,7 +256,12 @@ export class Koios implements Provider { _datum_hashes: [datumHash], }; const result = await pipe( - _Koios.postWithSchemaValidation(url, body, S.Array(_Koios.DatumInfo)), + _Koios.postWithSchemaValidation( + url, + body, + S.Array(_Koios.DatumInfo), + _Koios.getHeadersWithToken(this.token), + ), Effect.flatMap((result) => result.length === 0 ? Effect.fail("No Datum Found by Datum Hash") @@ -252,7 +281,12 @@ export class Koios implements Provider { }; const schema = S.Array(_Koios.TxInfoSchema); const result = await pipe( - _Koios.postWithSchemaValidation(url, body, schema), + _Koios.postWithSchemaValidation( + url, + body, + schema, + _Koios.getHeadersWithToken(this.token), + ), Effect.repeat({ schedule: Schedule.exponential(checkInterval), until: (result) => result.length > 0, @@ -271,9 +305,14 @@ export class Koios implements Provider { const body = fromHex(tx); const schema = _Koios.TxHashSchema; const result = await pipe( - _Koios.postWithSchemaValidation(url, body, schema, { - "Content-Type": "application/cbor", - }), + _Koios.postWithSchemaValidation( + url, + body, + schema, + _Koios.getHeadersWithToken(this.token, { + "Content-Type": "application/cbor", + }), + ), Effect.timeout(10_000), Effect.catchAllCause((cause) => new KoiosError({ cause })), Effect.runPromise, @@ -298,7 +337,12 @@ export class Koios implements Provider { }; const schema = _Ogmios.JSONRPCSchema(S.Array(_Ogmios.RedeemerSchema)); const result = await pipe( - _Koios.postWithSchemaValidation(url, body, schema), + _Koios.postWithSchemaValidation( + url, + body, + schema, + _Koios.getHeadersWithToken(this.token), + ), Effect.flatMap((response) => "error" in response ? Effect.fail(response) diff --git a/packages/provider/test/koios.test.ts b/packages/provider/test/koios.test.ts index 6cee04b5..8edf0b1c 100644 --- a/packages/provider/test/koios.test.ts +++ b/packages/provider/test/koios.test.ts @@ -1,8 +1,9 @@ import { Koios } from "../src/koios.js"; import { ProtocolParameters, UTxO } from "@lucid-evolution/core-types"; -import { assert, describe, expect, test, vi } from "vitest"; +import { assert, beforeEach, describe, expect, test, vi } from "vitest"; import * as PreprodConstants from "./preprod-constants.js"; import * as _Koios from "../src/internal/koios.js"; +import { Effect } from "effect"; //TODO: improve test assetion describe.sequential("Koios", () => { @@ -154,4 +155,214 @@ describe.sequential("Koios", () => { PreprodConstants.evalSample4.redeemersExUnits, ); }); + + describe("Token Authentication", () => { + const MOCK_TOKEN = "test-token-123"; + const BASE_URL = "https://preprod.koios.rest/api/v1"; + let koiosWithToken: Koios; + + beforeEach(() => { + vi.restoreAllMocks(); + koiosWithToken = new Koios(BASE_URL, MOCK_TOKEN); + }); + + test("getHeadersWithToken utility function", () => { + // Test with token + const headersWithToken = _Koios.getHeadersWithToken(MOCK_TOKEN); + expect(headersWithToken).toEqual({ + Authorization: `Bearer ${MOCK_TOKEN}`, + }); + + // Test without token + const headersWithoutToken = _Koios.getHeadersWithToken(undefined); + expect(headersWithoutToken).toEqual({}); + + // Test merging with existing headers + const existingHeaders = { "Content-Type": "application/json" }; + const mergedHeaders = _Koios.getHeadersWithToken( + MOCK_TOKEN, + existingHeaders, + ); + expect(mergedHeaders).toEqual({ + Authorization: `Bearer ${MOCK_TOKEN}`, + "Content-Type": "application/json", + }); + }); + + test("constructor properly stores token", () => { + // @ts-expect-error accessing private field for testing + expect(koiosWithToken.token).toBe(MOCK_TOKEN); + + const koiosWithoutToken = new Koios("https://preprod.koios.rest/api/v1"); + // @ts-expect-error accessing private field for testing + expect(koiosWithoutToken.token).toBeUndefined(); + }); + + test("getProtocolParameters includes token", async () => { + const mockResponse = [ + { + min_fee_a: 44, + min_fee_b: 155381, + max_tx_size: 16384, + max_val_size: 5000, + key_deposit: "2000000", + pool_deposit: "500000000", + price_mem: 0.0577, + price_step: 0.0000721, + max_tx_ex_mem: 14000000, + max_tx_ex_steps: 10000000000, + max_block_ex_mem: 62000000, + max_block_ex_steps: 20000000000, + extra_entropy: null, + protocol_major: 8, + protocol_minor: 0, + min_utxo_value: "1000000", + min_pool_cost: "340000000", + nonce: "1", + block_hash: null, + collateral_percent: 150, + max_collateral_inputs: 3, + coins_per_utxo_size: "4310", + cost_models: { + PlutusV1: [1], + PlutusV2: [1], + PlutusV3: [1], + }, + epoch_no: 1, + max_block_size: 90112, + max_bh_size: 1100, + min_fee_ref_script_cost_per_byte: 4310, + max_epoch: 18, + optimal_pool_count: 500, + influence: 0.3, + monetary_expand_rate: 0.003, + treasury_growth_rate: 0.2, + decentralisation: 0, + pvt_motion_no_confidence: 1, + pvt_committee_normal: 1, + pvt_committee_no_confidence: 1, + pvt_hard_fork_initiation: 1, + pvtpp_security_group: 1, + dvt_motion_no_confidence: 1, + dvt_committee_normal: 1, + dvt_committee_no_confidence: 1, + dvt_update_to_constitution: 1, + dvt_hard_fork_initiation: 1, + dvt_p_p_network_group: 1, + dvt_p_p_economic_group: 1, + dvt_p_p_technical_group: 1, + dvt_p_p_gov_group: 1, + dvt_treasury_withdrawal: 1, + committee_min_size: 1, + committee_max_term_length: 1, + gov_action_lifetime: 1, + gov_action_deposit: "1", + drep_deposit: "1", + drep_activity: 1, + }, + ]; + + const fetchSpy = vi.spyOn(global, "fetch").mockImplementation( + async () => + ({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(mockResponse), + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }) as Response, + ); + + await koiosWithToken.getProtocolParameters(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, options] = fetchSpy.mock.calls[0]; + + expect(url.toString()).toBe(`${BASE_URL}/epoch_params?limit=1`); + expect(options?.method).toBe("GET"); + const headers = options?.headers as Headers; + expect(headers.get("authorization")).toBe(`Bearer ${MOCK_TOKEN}`); + + fetchSpy.mockRestore(); + }); + + test("getDatum includes token", async () => { + const mockResponse = [ + { + bytes: "mock-datum", + }, + ]; + + const fetchSpy = vi.spyOn(global, "fetch").mockImplementation( + async () => + ({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(mockResponse), + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }) as Response, + ); + + const datumHash = "test-datum-hash"; + await koiosWithToken.getDatum(datumHash); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, options] = fetchSpy.mock.calls[0]; + + // URL and method verification + expect(url.toString()).toBe(`${BASE_URL}/datum_info`); + expect(options?.method).toBe("POST"); + + // Headers verification + const headers = options?.headers as Headers; + expect(headers.get("authorization")).toBe(`Bearer ${MOCK_TOKEN}`); + expect(headers.get("content-type")).toBe("application/json"); + + // Body verification + const decoder = new TextDecoder(); + const bodyText = decoder.decode(options?.body as Uint8Array); + const bodyJson = JSON.parse(bodyText); + expect(bodyJson).toEqual({ + _datum_hashes: [datumHash], + }); + + fetchSpy.mockRestore(); + }); + + test("submitTx includes token with CBOR headers", async () => { + const mockTransaction = PreprodConstants.evalSample1.transaction; + const expectedTxHash = + "e84eb47208757db8ed101c2114ca8953527b4a6aae51bacf17e991e5c734fec6"; + + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ + "content-type": "application/json", + }), + text: () => Promise.resolve(JSON.stringify(expectedTxHash)), + json: () => Promise.resolve(expectedTxHash), + body: null, + } as Response); + + expect(await koiosWithToken.submitTx(mockTransaction)).toBe( + expectedTxHash, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, options] = fetchSpy.mock.calls[0]; + + expect(url.toString()).toBe(`${BASE_URL}/submittx`); + expect(options?.method).toBe("POST"); + const headers = options?.headers as Headers; + expect(headers.get("authorization")).toBe(`Bearer ${MOCK_TOKEN}`); + expect(headers.get("content-type")).toBe("application/cbor"); + + fetchSpy.mockRestore(); + }); + }); });