diff --git a/docs/docs/getting-started/choose-provider.md b/docs/docs/getting-started/choose-provider.md index 57e70046..f9a7159c 100644 --- a/docs/docs/getting-started/choose-provider.md +++ b/docs/docs/getting-started/choose-provider.md @@ -22,6 +22,12 @@ const lucid = await Lucid.new( ); ``` +```js +import { Lucid, Koios } from "https://deno.land/x/lucid/mod.ts"; + +const lucid = await Lucid.new(new Koios("Preprod"), "Preprod"); +``` + ### Kupmios Kupmios is a mix of [Ogmios](https://ogmios.dev/) and @@ -54,6 +60,19 @@ const lucid = await Lucid.new( ); ``` +### Koios + +```js +import { Lucid, Koios } from "https://deno.land/x/lucid/mod.ts"; + +const lucid = await Lucid.new( + new Koios( + "https://prepod.koios.rest/api/v0", // For MAINNET: "https://api.koios.rest/api/v0". + ), + "Preprod", // For MAINNET: "Mainnet". +); +``` + ### Custom Lucid may add more providers in the future, but you also have the option to diff --git a/docs/index.yml b/docs/index.yml index a7888682..6baf9868 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -135,6 +135,11 @@ usage: ), ); ``` + ```js { title="koios.js" } + import { Lucid, Koios } from "https://deno.land/x/lucid/mod.ts" + + const lucid = await Lucid.new(new Koios("Preprod"), "Preprod"); + ``` ```js { title="kupmios.js" } import { Lucid, Kupmios } from "https://deno.land/x/lucid/mod.ts" diff --git a/src/provider/koios.ts b/src/provider/koios.ts new file mode 100644 index 00000000..3b121814 --- /dev/null +++ b/src/provider/koios.ts @@ -0,0 +1,276 @@ +import {applyDoubleCborEncoding, fromHex, fromUnit} from "../utils/utils.ts"; +import { + Address, + Assets, + Credential, + Datum, + DatumHash, + Delegation, + OutRef, + ProtocolParameters, + Provider, + RewardAddress, + Transaction, + TxHash, + Unit, + UTxO, +} from "../types/mod.ts"; + +export class Koios implements Provider { + + private readonly baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + async getProtocolParameters(): Promise { + const result = await fetch(`${this.baseUrl}/epoch_params?limit=1`).then((res) => res.json()); + + return { + minFeeA: parseInt(result[0].min_fee_a), + minFeeB: parseInt(result[0].min_fee_b), + maxTxSize: parseInt(result[0].max_tx_size), + maxValSize: parseInt(result[0].max_val_size), + keyDeposit: BigInt(result[0].key_deposit), + poolDeposit: BigInt(result[0].pool_deposit), + priceMem: parseFloat(result[0].price_mem), + priceStep: parseFloat(result[0].price_step), + maxTxExMem: BigInt(result[0].max_tx_ex_mem), + maxTxExSteps: BigInt(result[0].max_tx_ex_steps), + coinsPerUtxoByte: BigInt(result[0].coins_per_utxo_size), + collateralPercentage: parseInt(result[0].collateral_percent), + maxCollateralInputs: parseInt(result[0].max_collateral_inputs), + costModels: JSON.parse(result[0].cost_models), + }; + } + + async getUtxos(addressOrCredential: Address | Credential): Promise { + if (typeof addressOrCredential === "string") { + const body = { + '_addresses': [addressOrCredential] + } + const result: KoiosAddressInfoResponse = await fetch(`${this.baseUrl}/address_info`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: "POST", + body: JSON.stringify(body) + }).then((res: Response) => res.json()); + if (Array.isArray(result) && result.length > 0 && result[0].utxo_set && result[0].utxo_set.length > 0) { + return (await this.koiosUtxosToUtxos(result[0].utxo_set, result[0].address)) + } else { + return [] + } + } else { + throw Error('getUtxos by Credential Type is not supported in Koios yet.') + } + } + + private async koiosUtxosToUtxos(result: Array, address?: string): Promise { + return (await Promise.all( + result.map((r: KoiosUTxO) => ({ + txHash: r.tx_hash, + outputIndex: r.tx_index, + assets: (() => { + const a: Assets = {}; + r.asset_list.forEach((am: KoiosAsset) => { + a[am.policy_id + am.asset_name] = BigInt(am.quantity); + }); + return a; + })(), + address: address, + datumHash: !r.inline_datum ? r.datum_hash : undefined, + datum: r.inline_datum, + scriptRef: { + type: r.reference_script ? r.reference_script.type : null, + script: r.reference_script ? applyDoubleCborEncoding(r.reference_script.bytes) : null + }, + })), + )) as UTxO[]; + } + + async getUtxosWithUnit(addressOrCredential: Address | Credential, unit: Unit): Promise { + if (typeof addressOrCredential === "string") { + const utxos = await this.getUtxos(addressOrCredential); + if (utxos && utxos.length > 0) { + return utxos.filter((utxo): utxo is UTxO => { + const keys = Object.keys(utxo.assets) + return keys.length > 0 && keys.includes(unit) + }) + } else { + return [] + } + } else { + throw Error('getUtxosWithUnit by Credential Type is not supported in Koios yet.') + } + } + + async getUtxoByUnit(unit: Unit): Promise { + let {policyId, assetName} = fromUnit(unit) + assetName = String(assetName) + const assetAddresses = await fetch(`${this.baseUrl}/asset_addresses?_asset_policy=${policyId}&_asset_name=${assetName}`) + .then((res: Response) => res.json()); + if (Array.isArray(assetAddresses) && assetAddresses.length > 0) { + if (assetAddresses.length > 1) { + throw new Error("Unit needs to be an NFT or only held by one address."); + } + const utxos: UTxO[] = await this.getUtxos(assetAddresses[0].payment_address) + const result = utxos.find((utxo): utxo is UTxO => { + const keys = Object.keys(utxo.assets) + return keys.length > 0 && keys.includes(unit) + }) + if (result) { + return result + } + } + throw new Error("Unit not found."); + } + + async getUtxosByOutRef(outRefs: OutRef[]): Promise { + const utxos = [] + const body = { + '_tx_hashes': [...new Set(outRefs.map((outRef) => outRef.txHash))] + } + const result = await fetch(`${this.baseUrl}/tx_utxos`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: "POST", + body: JSON.stringify(body) + }).then((res: Response) => res.json()); + if (Array.isArray(result) && result.length > 0) { + for (const utxo of result) { + if (utxo.outputs && utxo.outputs.length > 0) { + utxos.push(await this.koiosUtxosToUtxos(utxo.outputs)) + } + } + return utxos.reduce((acc, utxos) => acc.concat(utxos), []).filter((utxo) => + outRefs.some((outRef) => + utxo.txHash === outRef.txHash && utxo.outputIndex === outRef.outputIndex + ) + ); + } else { + return [] + } + } + + async getDelegation(rewardAddress: RewardAddress): Promise { + const body = { + '_stake_addresses': [rewardAddress] + } + const result = await fetch(`${this.baseUrl}/account_info`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: "POST", + body: JSON.stringify(body) + }).then((res: Response) => res.json()); + if (Array.isArray(result) && result.length > 0) { + return { + poolId: result[0].delegated_pool || null, + rewards: BigInt(result[0].rewards_available), + } + } else { + throw new Error("No Delegation Found by Reward Address"); + } + } + + async getDatum(datumHash: DatumHash): Promise { + const body = { + '_datum_hashes': [datumHash] + } + const datum = await fetch(`${this.baseUrl}/datum_info`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: "POST", + body: JSON.stringify(body) + }).then((res: Response) => res.json()); + if (Array.isArray(datum) && datum.length > 0) { + return datum[0].bytes + } else { + throw new Error("No Datum Found by Datum Hash"); + } + } + + awaitTx(txHash: TxHash, checkInterval = 3000): Promise { + return new Promise((res) => { + const confirmation = setInterval(async () => { + const body = { + '_tx_hashes': [txHash] + } + const result = await fetch(`${this.baseUrl}/tx_info`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + method: "POST", + body: JSON.stringify(body) + }).then((res: Response) => res.json()); + if (Array.isArray(result) && result.length > 0) { + clearInterval(confirmation); + await new Promise((res) => setTimeout(() => res(1), 1000)); + return res(true) + } + }, checkInterval); + }); + } + + async submitTx(tx: Transaction): Promise { + const result = await fetch(`${this.baseUrl}/tx_info`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/cbor' + }, + method: "POST", + body: fromHex(tx) + }).then((res: Response) => res.json()) + if (!result || result.error) { + if (result?.status_code === 400) throw new Error(result.message); + else throw new Error("Could not submit transaction."); + } + return result + } +} + +type KoiosAddressInfoResponse = Array<{ + address: Address; + balance: string; + stake_address?: string + script_address: string + utxo_set: Array +}>; + +type KoiosUTxO = { + tx_hash: string; + tx_index: number; + block_height?: number; + block_time: number; + value: string; + datum_hash?: string; + inline_datum?: { + bytes: string; + value: object; + } + reference_script?: { + hash: string; + size: number; + type: string; + bytes: string; + value?: object; + } + asset_list: Array +} + +type KoiosAsset = { + policy_id: string; + asset_name?: string; + fingerprint: string; + decimals: number; + quantity: string; +}; \ No newline at end of file diff --git a/src/provider/mod.ts b/src/provider/mod.ts index 64a82993..3680736b 100644 --- a/src/provider/mod.ts +++ b/src/provider/mod.ts @@ -2,3 +2,4 @@ export * from "./blockfrost.ts"; export * from "./kupmios.ts"; export * from "./emulator.ts"; export * from "./maestro.ts"; +export * from "./koios.ts"; diff --git a/tests/koios.test.ts b/tests/koios.test.ts new file mode 100644 index 00000000..f4307aad --- /dev/null +++ b/tests/koios.test.ts @@ -0,0 +1,92 @@ +import {Koios} from "../src/provider/koios.ts"; +import {Datum, Delegation, ProtocolParameters, UTxO} from "../src/types/types.ts"; +import {assert} from "https://deno.land/std@0.145.0/testing/asserts.ts"; +import {Lucid} from "../src/lucid/lucid.ts"; + +const koios = new Koios("https://api.koios.rest/api/v0") + +Deno.test("getProtocolParameters", async () => { + try { + const pp: ProtocolParameters = await koios.getProtocolParameters(); + assert(pp); + } catch (e) { + console.log(e) + } +}); + +Deno.test("getUtxos", async () => { + try { + const utxos: UTxO[] = await koios.getUtxos("addr1qy2jt0qpqz2z2z9zx5w4xemekkce7yderz53kjue53lpqv90lkfa9sgrfjuz6uvt4uqtrqhl2kj0a9lnr9ndzutx32gqleeckv"); + assert(utxos); + } catch (e) { + console.log(e) + } +}); + +Deno.test("getUtxosWithUnit", async () => { + try { + const utxos: UTxO[] = await koios.getUtxosWithUnit( + "addr1q8vaadv0h7atv366u6966u4rft2svjlf5uajy8lkpsgdrc24rnskuetxz2u3m5ac22s3njvftxcl2fc8k8kjr088ge0qpn6xhn", + "85152e10643c1440ba2ba817e3dd1faf7bd7296a8b605efd0f0f2d1844696d656e73696f6e426f78202330313739"); + console.log(utxos) + assert(utxos); + } catch (e) { + console.log(e) + } +}); + +Deno.test("getUtxoByUnit", async () => { + try { + const utxo: UTxO = await koios.getUtxoByUnit("85152e10643c1440ba2ba817e3dd1faf7bd7296a8b605efd0f0f2d1844696d656e73696f6e426f78202330313739"); + assert(utxo); + } catch (e) { + console.log(e) + } +}); + +Deno.test("getUtxosByOutRef", async () => { + try { + const utxos: UTxO[] = await koios.getUtxosByOutRef([{txHash: 'c6ee20549eab1e565a4bed119bb8c7fc2d11cc5ea5e1e25433a34f0175c0bef6', outputIndex: 0}]); + assert(utxos); + } catch (e) { + console.log(e) + } +}); + +Deno.test("getDelegation", async () => { + const lucid = await Lucid.new(koios, "Mainnet"); + assert(lucid) + try { + const delegation: Delegation = await lucid.delegationAt('stake1uyrx65wjqjgeeksd8hptmcgl5jfyrqkfq0xe8xlp367kphsckq250'); + assert(delegation); + } catch (e) { + console.log(e) + } +}); + +Deno.test("getDatum", async () => { + try { + const datum: Datum = await koios.getDatum('818ee3db3bbbd04f9f2ce21778cac3ac605802a4fcb00c8b3a58ee2dafc17d46'); + assert(datum); + } catch (e) { + console.log(e) + } +}); + +Deno.test("awaitTx", async () => { + try { + const isConfirmed: boolean = await koios.awaitTx('f144a8264acf4bdfe2e1241170969c930d64ab6b0996a4a45237b623f1dd670e'); + assert(isConfirmed); + } catch (e) { + console.log(e) + } +}); + +Deno.test("submitTxBadRequest", async () => { + try { + const txId: string = await koios.submitTx('80'); + assert(txId); + } catch (e) { + console.log(e) + } +});