diff --git a/changelog.d/20240103_085440_nicolas.henin_runtimeStatus.md b/changelog.d/20240103_085440_nicolas.henin_runtimeStatus.md new file mode 100644 index 00000000..6527f6d8 --- /dev/null +++ b/changelog.d/20240103_085440_nicolas.henin_runtimeStatus.md @@ -0,0 +1,3 @@ +### @marlowe.io/runtime-rest-client + +- Added a new endpoint `GetRuntimeStatus` that retrieves informations about the Runtime (version deployed, Network Id of the Node and tips) diff --git a/packages/adapter/src/http.ts b/packages/adapter/src/http.ts index 02f9f868..b7db6bd2 100644 --- a/packages/adapter/src/http.ts +++ b/packages/adapter/src/http.ts @@ -15,12 +15,20 @@ const getWithDataAndHeaders = TE.bimap( (v: AxiosResponse): any => [v.headers, v.data] ); +const getWithHeaders = TE.bimap( + (e: unknown) => (e instanceof Error ? e : new Error(String(e))), + (v: AxiosResponse): any => v.headers +); + export const Get = (request: AxiosInstance) => flow(TE.tryCatchK(request.get, identity), getOnlyData); export const GetWithDataAndHeaders = (request: AxiosInstance) => flow(TE.tryCatchK(request.get, identity), getWithDataAndHeaders); +export const GetWithHeaders = (request: AxiosInstance) => + flow(TE.tryCatchK(request.get, identity), getWithHeaders); + export const Post = (request: AxiosInstance) => flow(TE.tryCatchK(request.post, identity), getOnlyData); diff --git a/packages/adapter/src/index.ts b/packages/adapter/src/index.ts index 5ae1d12c..f44b885f 100644 --- a/packages/adapter/src/index.ts +++ b/packages/adapter/src/index.ts @@ -1,2 +1,2 @@ -export * as MarloweJSON from "./codec.js"; +export * as Codec from "./codec.js"; export * as Time from "./time.js"; diff --git a/packages/runtime/client/rest/src/guards.ts b/packages/runtime/client/rest/src/guards.ts index e5261779..eb92f510 100644 --- a/packages/runtime/client/rest/src/guards.ts +++ b/packages/runtime/client/rest/src/guards.ts @@ -26,3 +26,8 @@ export { } from "./contract/rolesConfigurations.js"; export { ContractDetailsGuard as ContractDetails } from "./contract/details.js"; + +export { + CompatibleRuntimeVersionGuard as CompatibleRuntimeVersion, + TipGuard as Tip, +} from "./runtime/status.js"; diff --git a/packages/runtime/client/rest/src/index.ts b/packages/runtime/client/rest/src/index.ts index 775db7d2..cb8644fd 100644 --- a/packages/runtime/client/rest/src/index.ts +++ b/packages/runtime/client/rest/src/index.ts @@ -40,6 +40,7 @@ import { submitContractViaAxios } from "./contract/endpoints/singleton.js"; import { ContractDetails } from "./contract/details.js"; import { TransactionDetails } from "./contract/transaction/details.js"; import { ItemRange } from "./pagination.js"; +import { RuntimeStatus, getRuntimeStatus } from "./runtime/status.js"; // import curlirize from 'axios-curlirize'; export { @@ -63,6 +64,11 @@ export { * This version of the RestClient targets version `0.0.5` of the Marlowe Runtime. */ export interface RestClient { + /** + * Get a Set of Runtime Environment information (Tips, NetworkId and Runtime Version Deployed) + */ + getRuntimeStatus(): Promise; + /** * Gets a paginated list of contracts {@link contract.ContractHeader } * @param request Optional filtering and pagination options. @@ -270,6 +276,9 @@ export function mkRestClient(baseURL: string): RestClient { }); return { + getRuntimeStatus() { + return getRuntimeStatus(axiosInstance); + }, getContracts(request) { const range = request?.range; const tags = request?.tags ?? []; diff --git a/packages/runtime/client/rest/src/runtime/index.ts b/packages/runtime/client/rest/src/runtime/index.ts new file mode 100644 index 00000000..c9e56c5e --- /dev/null +++ b/packages/runtime/client/rest/src/runtime/index.ts @@ -0,0 +1 @@ +export * from "./status.js"; diff --git a/packages/runtime/client/rest/src/runtime/status.ts b/packages/runtime/client/rest/src/runtime/status.ts new file mode 100644 index 00000000..0271462c --- /dev/null +++ b/packages/runtime/client/rest/src/runtime/status.ts @@ -0,0 +1,103 @@ +import { AxiosInstance } from "axios"; +import * as HTTP from "@marlowe.io/adapter/http"; +import { unsafeEither, unsafeTaskEither } from "@marlowe.io/adapter/fp-ts"; +import { ISO8601 } from "@marlowe.io/adapter/time"; +import { + BlockHeader, + BlockHeaderGuard, + NetworkId, + bigintGuard, +} from "@marlowe.io/runtime-core"; +import { formatValidationErrors } from "jsonbigint-io-ts-reporters"; +import * as E from "fp-ts/lib/Either.js"; +import * as t from "io-ts/lib/index.js"; +import { MarloweJSON, MarloweJSONCodec } from "@marlowe.io/adapter/codec"; +export type BlockHash = string; + +export type RuntimeVersion = string; + +export type CompatibleRuntimeVersion = "0.0.6" | "0.0.5"; + +export const CompatibleRuntimeVersionGuard: t.Type< + CompatibleRuntimeVersion, + string +> = t.union([t.literal("0.0.6"), t.literal("0.0.5")]); + +/** + * A **Tip** represents the last block read in a "projection" process. + * In the context of Cardano and Blockchain in general, a Projection is about deriving a state from a ledger by streaming the block events. + * These States (e.g : contract details) are eventually consistent, and the Tip gives you an approximate notion on how freshly updated they are. + */ +export type Tip = { + /** + * Last Block Header Read + */ + blockHeader: BlockHeader; + /** + * Last Slot Read in UTC time + */ + slotTimeUTC: ISO8601; +}; +/** + * @hidden + */ +export const TipGuard = t.type({ + slotTimeUTC: ISO8601, + blockHeader: BlockHeaderGuard, +}); + +/** + * Set of information about the runtime hosted + */ +export type RuntimeStatus = { + /** + * Network ID of the node connected to the runtime + */ + networkId: NetworkId; + /** + * Runtime Version Deployed + */ + version: RuntimeVersion; + /** + * Set of Tips providing information on how healthy is the flow of Projections : Node > Runtime Chain > Runtime + * The Runtime Tip indicates if the information Queried is up to date. The Node and the Runtime Chain Tips are + * here to help the diagnostic of a Runtime Tip that would be too long in the past or not being updated anymore. + */ + tips: { + node: Tip; + runtimeChain: Tip; + runtime: Tip; + }; +}; + +export const getRuntimeStatus = async ( + axiosInstance: AxiosInstance +): Promise => { + const headers = await unsafeTaskEither( + HTTP.GetWithHeaders(axiosInstance)("/healthcheck") + ); + + return { + networkId: headers["x-network-id"], + version: headers["x-runtime-version"], + tips: { + node: unsafeEither( + E.mapLeft(formatValidationErrors)( + TipGuard.decode(MarloweJSONCodec.decode(headers["x-node-tip"])) + ) + ), + runtimeChain: unsafeEither( + E.mapLeft(formatValidationErrors)( + TipGuard.decode( + MarloweJSONCodec.decode(headers["x-runtime-chain-tip"]) + ) + ) + ), + runtime: unsafeEither( + E.mapLeft(formatValidationErrors)( + TipGuard.decode(MarloweJSONCodec.decode(headers["x-runtime-tip"])) + ) + ), + }, + }; +}; diff --git a/packages/runtime/client/rest/test/endpoints/runtime.spec.e2e.ts b/packages/runtime/client/rest/test/endpoints/runtime.spec.e2e.ts new file mode 100644 index 00000000..96499cfe --- /dev/null +++ b/packages/runtime/client/rest/test/endpoints/runtime.spec.e2e.ts @@ -0,0 +1,18 @@ +import { mkRestClient } from "@marlowe.io/runtime-rest-client"; + +import { getMarloweRuntimeUrl } from "../context.js"; + +import console from "console"; +import * as G from "@marlowe.io/runtime-rest-client/guards"; +import { MarloweJSON } from "@marlowe.io/adapter/codec"; + +global.console = console; + +describe("Runtime", () => { + const restClient = mkRestClient(getMarloweRuntimeUrl()); + it("is deployed with a version compatible with @marlowe.io/runtime-rest-client.", async () => { + const status = await restClient.getRuntimeStatus(); + console.log("status", MarloweJSON.stringify(status)); + expect(G.CompatibleRuntimeVersion.is(status.version)).toBe(true); + }, 100_000); +}); diff --git a/packages/runtime/client/rest/typedoc.json b/packages/runtime/client/rest/typedoc.json index 47e8da51..5c3b876f 100644 --- a/packages/runtime/client/rest/typedoc.json +++ b/packages/runtime/client/rest/typedoc.json @@ -2,6 +2,7 @@ "entryPointStrategy": "expand", "entryPoints": [ "./src/index.ts", + "./src/runtime/index.ts", "./src/contract/index.ts", "./src/payout/index.ts", "./src/withdrawal/index.ts" diff --git a/packages/runtime/core/src/index.ts b/packages/runtime/core/src/index.ts index 09dc4331..60eb9e60 100644 --- a/packages/runtime/core/src/index.ts +++ b/packages/runtime/core/src/index.ts @@ -9,3 +9,4 @@ export * from "./contract/id.js"; export * from "./sourceId.js"; export * from "./asset/index.js"; export * from "./payout/index.js"; +export * from "./network.js"; diff --git a/packages/runtime/core/src/network.ts b/packages/runtime/core/src/network.ts new file mode 100644 index 00000000..f40432df --- /dev/null +++ b/packages/runtime/core/src/network.ts @@ -0,0 +1,24 @@ +import * as t from "io-ts/lib/index.js"; + +export type NetworkId = bigint; +export type Network = "preview" | "preprod" | "mainnet" | "private"; + +export const NetworkGuard: t.Type = t.union([ + t.literal("preview"), + t.literal("preprod"), + t.literal("mainnet"), + t.literal("private"), +]); + +export const getNetwork = (networkId: NetworkId): Network => { + switch (networkId) { + case 0n: + return "mainnet"; + case 1n: + return "preprod"; + case 2n: + return "preview"; + default: + return "private"; + } +}; diff --git a/packages/runtime/lifecycle/test/context.ts b/packages/runtime/lifecycle/test/context.ts index 9bf0eed9..86b3f8f0 100644 --- a/packages/runtime/lifecycle/test/context.ts +++ b/packages/runtime/lifecycle/test/context.ts @@ -1,21 +1,32 @@ -import { Network } from "lucid-cardano"; +import { Network, NetworkGuard, getNetwork } from "@marlowe.io/runtime-core"; import { Context, getPrivateKeyFromHexString } from "@marlowe.io/wallet/nodejs"; +import { formatValidationErrors } from "jsonbigint-io-ts-reporters"; +import { unsafeEither, unsafeTaskEither } from "@marlowe.io/adapter/fp-ts"; +import * as E from "fp-ts/lib/Either.js"; export function getBlockfrostContext(): Context { - const { BLOCKFROST_URL, BLOCKFROST_PROJECT_ID, NETWORK_ID } = process.env; + const { BLOCKFROST_URL, BLOCKFROST_PROJECT_ID } = process.env; if (BLOCKFROST_URL == undefined) - throw "environment configurations not available (BLOCKFROST_URL)"; + throw "Test environment variable not defined (BLOCKFROST_URL)"; if (BLOCKFROST_PROJECT_ID == undefined) - throw "environment configurations not available (BLOCKFROST_PROJECT_ID)"; - if (NETWORK_ID == undefined) - throw "environment configurations not available (NETWORK_ID)"; + throw "Test environment variable not defined (BLOCKFROST_PROJECT_ID)"; + return new Context( BLOCKFROST_PROJECT_ID as string, BLOCKFROST_URL as string, - NETWORK_ID as Network + getNetworkTestConfiguration() ); } +export const getNetworkTestConfiguration = (): Network => { + const { NETWORK_NAME } = process.env; + if (NETWORK_NAME == undefined) + throw "Test environment variable not defined (NETWORK_NAME) "; + return unsafeEither( + E.mapLeft(formatValidationErrors)(NetworkGuard.decode(NETWORK_NAME)) + ); +}; + export function getBankPrivateKey(): string { const { BANK_PK_HEX } = process.env; if (BANK_PK_HEX == undefined) diff --git a/packages/wallet/src/nodejs/index.ts b/packages/wallet/src/nodejs/index.ts index 34401e4a..b46e3c81 100644 --- a/packages/wallet/src/nodejs/index.ts +++ b/packages/wallet/src/nodejs/index.ts @@ -2,8 +2,8 @@ import * as API from "@blockfrost/blockfrost-js"; import { Blockfrost, Lucid, - C, Network, + C, PrivateKey, PolicyId, getAddressDetails, @@ -38,6 +38,7 @@ import { policyId, AssetId, } from "@marlowe.io/runtime-core"; +import * as RuntimeCore from "@marlowe.io/runtime-core"; import { WalletAPI } from "../api.js"; import * as Codec from "@47ng/codec"; import { MarloweJSON } from "@marlowe.io/adapter/codec"; @@ -50,18 +51,31 @@ export type Address = string; // TODO: This is a pure datatype, convert to type alias or interface export class Context { projectId: string; - network: Network; + network: RuntimeCore.Network; blockfrostUrl: string; public constructor( projectId: string, blockfrostUrl: string, - network: Network + network: RuntimeCore.Network ) { this.projectId = projectId; this.network = network; this.blockfrostUrl = blockfrostUrl; } + + public toLucidNetwork(): Network { + switch (this.network) { + case "private": + return "Custom"; + case "preview": + return "Preview"; + case "preprod": + return "Preprod"; + case "mainnet": + return "Mainnet"; + } + } } // [[testing-wallet-discussion]] @@ -119,7 +133,7 @@ export class SingleAddressWallet implements WalletAPI { private async initialise() { this.lucid = await Lucid.new( new Blockfrost(this.context.blockfrostUrl, this.context.projectId), - this.context.network + this.context.toLucidNetwork() ); this.lucid.selectWalletFromPrivateKey(this.privateKeyBech32); this.address = addressBech32(await this.lucid.wallet.address());