diff --git a/cabal.project b/cabal.project index fda7816fd2..a5de8a9c8e 100644 --- a/cabal.project +++ b/cabal.project @@ -29,6 +29,10 @@ if impl(ghc < 9.8) constraints: process >= 1.6.26.1 +-- It may slow down build plan preparation, but without it cabal has problems +-- with solving constraints. Remove this when not needed anymore. +max-backjumps: 50000 + program-options ghc-options: -Werror diff --git a/cardano-wasm/README.md b/cardano-wasm/README.md index 93beb1a378..fb6a591698 100644 --- a/cardano-wasm/README.md +++ b/cardano-wasm/README.md @@ -203,7 +203,7 @@ That will create it with the name `cardano-wasm.js` in the current folder. You can find more information in [this url](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/wasm.html). -And you can find an example of how to use it in the `example` subfolder. This example assumes that the generated `.wasm` and `.js` files reside in the same folder as the code in `example` subfolder. +And you can find an example of how to use it in the `example` subfolder. This example assumes that the generated `.wasm` and `.js` files as well as the files from the `lib-wrapper` subfolder, all reside in the same folder as the code in `example` subfolder. ## Running the example @@ -214,12 +214,13 @@ To run the example in the `example` subfolder: echo "$(env -u CABAL_CONFIG wasm32-wasi-cabal list-bin exe:cardano-wasm | tail -n1)" ``` 2. Copy the generated `cardano-wasm.js` file (generated by the `post-link.mjs` command in the section above) to the `example` subfolder. -3. Navigate to the `example` subfolder in your terminal. -4. Due to browser security restrictions (CORS policy), you may need to serve the `index.html` file through a local HTTP server. A simple way to do this is using Python's built-in HTTP server: +3. Copy the wrapper files from the `lib-wrapper` folder into the `example` subfolder. +4. Navigate to the `example` subfolder in your terminal. +5. Due to browser security restrictions (CORS policy), you may need to serve the `index.html` file through a local HTTP server. A simple way to do this is using Python's built-in HTTP server: ```console python3 -m http.server 8001 ``` -5. Open your web browser and navigate to `http://localhost:8001/`. You should see a blank page, and if you open the developer console you should be able to see an output like the following: +6. Open your web browser and navigate to `http://localhost:8001/`. You should see a blank page, and if you open the developer console you should be able to see an output like the following: ```console [Log] wasi: – 0 – 0 (wasi.js, line 1) [Log] wasi: – 0 – 0 (wasi.js, line 1) diff --git a/cardano-wasm/app/Main.hs b/cardano-wasm/app/Main.hs index a31c5a029d..af9b3cd321 100644 --- a/cardano-wasm/app/Main.hs +++ b/cardano-wasm/app/Main.hs @@ -1,4 +1,8 @@ -module Main (main) where +module Main where + +import Cardano.Wasm.Internal.Api.Info (apiInfo) +import Cardano.Wasm.Internal.Api.InfoToTypeScript (apiInfoToTypeScriptFile) +import Cardano.Wasm.Internal.Api.TypeScriptDefs (printTypeScriptFile) main :: IO () -main = pure () +main = printTypeScriptFile (apiInfoToTypeScriptFile apiInfo) diff --git a/cardano-wasm/cardano-wasm.cabal b/cardano-wasm/cardano-wasm.cabal index bce800b981..47b66bc41f 100644 --- a/cardano-wasm/cardano-wasm.cabal +++ b/cardano-wasm/cardano-wasm.cabal @@ -40,7 +40,9 @@ executable cardano-wasm "-optl-Wl,--strip-all,--export=getApiInfo,--export=newConwayTx,--export=addTxInput,--export=addSimpleTxOut,--export=setFee,--export=estimateMinFee,--export=signWithPaymentKey,--export=alsoSignWithPaymentKey,--export=txToCbor" other-modules: Cardano.Wasm.Internal.Api.Info + Cardano.Wasm.Internal.Api.InfoToTypeScript Cardano.Wasm.Internal.Api.Tx + Cardano.Wasm.Internal.Api.TypeScriptDefs Cardano.Wasm.Internal.ExceptionHandling Cardano.Wasm.Internal.JavaScript.Bridge @@ -59,3 +61,31 @@ executable cardano-wasm build-depends: ghc-experimental, utf8-string, + +test-suite cardano-wasm-golden + type: exitcode-stdio-1.0 + main-is: cardano-wasm-golden.hs + hs-source-dirs: test/cardano-wasm-golden + + if !arch(wasm32) + import: project-config + build-depends: + hedgehog >=1.1, + hedgehog-extras ^>=0.8, + tasty, + tasty-hedgehog, + + ghc-options: + -threaded + -rtsopts + "-with-rtsopts=-N -T" + + build-tool-depends: + cardano-wasm:cardano-wasm, + tasty-discover:tasty-discover, + + other-modules: + Test.Golden.Cardano.Wasm.TypeScript + else + build-depends: + base diff --git a/cardano-wasm/example/example.js b/cardano-wasm/example/example.js new file mode 100644 index 0000000000..4c58158830 --- /dev/null +++ b/cardano-wasm/example/example.js @@ -0,0 +1,38 @@ +import cardano_api from "./cardano-api.js"; + +let promise = cardano_api(); + +async function get_protocol_params() { + const response = await fetch("./preview_pparams.json"); + return (await response.json()); +} + +let protocolParams = await get_protocol_params(); + +async function do_async_work() { + let api = await promise; + console.log("Api object:"); + console.log(api); + + let emptyTx = await api.newConwayTx(); + console.log("UnsignedTx object:"); + console.log(emptyTx); + + let tx = await emptyTx.addTxInput("be6efd42a3d7b9a00d09d77a5d41e55ceaf0bd093a8aa8a893ce70d9caafd978", 0) + .addSimpleTxOut("addr_test1vzpfxhjyjdlgk5c0xt8xw26avqxs52rtf69993j4tajehpcue4v2v", 10_000_000n) + + let feeEstimate = await tx.estimateMinFee(protocolParams, 1, 0, 0); + console.log("Estimated fee:"); + console.log(feeEstimate); + + let signedTx = await tx.setFee(feeEstimate) + .signWithPaymentKey("addr_sk1648253w4tf6fv5fk28dc7crsjsaw7d9ymhztd4favg3cwkhz7x8sl5u3ms"); + console.log("SignedTx object:"); + console.log(signedTx); + + let txCbor = await signedTx.txToCbor(); + console.log("Tx CBOR:"); + console.log(txCbor); +} + +do_async_work().then(() => { }); diff --git a/cardano-wasm/example/index.html b/cardano-wasm/example/index.html index 25be4749c7..2efd25a184 100644 --- a/cardano-wasm/example/index.html +++ b/cardano-wasm/example/index.html @@ -1,41 +1,7 @@ - + \ No newline at end of file diff --git a/cardano-wasm/lib-wrapper/cardano-api.d.ts b/cardano-wasm/lib-wrapper/cardano-api.d.ts new file mode 100644 index 0000000000..3ea228b2ca --- /dev/null +++ b/cardano-wasm/lib-wrapper/cardano-api.d.ts @@ -0,0 +1,99 @@ +// cardano-api.d.ts + +export default initialise; + +/** + * Initialises the Cardano API. + * @returns A promise that resolves to the main `CardanoAPI` object. + */ +declare function initialise(): Promise; + +/** + * Represents an unsigned transaction. + */ +declare interface UnsignedTx { + /** + * The type of the object, used for identification (the "UnsignedTx" string). + */ + objectType: string; + + /** + * Adds a simple transaction input to the transaction. + * @param txId The transaction ID of the input UTxO. + * @param txIx The index of the input within the UTxO. + * @returns The `UnsignedTx` object with the added input. + */ + addTxInput(txId: string, txIx: number): UnsignedTx; + + /** + * Adds a simple transaction output to the transaction. + * @param destAddr The destination address. + * @param lovelaceAmount The amount in lovelaces to output. + * @returns The `UnsignedTx` object with the added output. + */ + addSimpleTxOut(destAddr: string, lovelaceAmount: bigint): UnsignedTx; + + /** + * Sets the fee for the transaction. + * @param lovelaceAmount The fee amount in lovelaces. + * @returns The `UnsignedTx` object with the set fee. + */ + setFee(lovelaceAmount: bigint): UnsignedTx; + + /** + * Estimates the minimum fee for the transaction. + * @param protocolParams The protocol parameters. + * @param numKeyWitnesses The number of key witnesses. + * @param numByronKeyWitnesses The number of Byron key witnesses. + * @param totalRefScriptSize The total size of reference scripts in bytes. + * @returns A promise that resolves to the estimated minimum fee in lovelaces. + */ + estimateMinFee(protocolParams: any, numKeyWitnesses: number, numByronKeyWitnesses: number, totalRefScriptSize: number): Promise; + + /** + * Signs the transaction with a payment key. + * @param signingKey The signing key to witness the transaction. + * @returns A promise that resolves to a `SignedTx` object. + */ + signWithPaymentKey(signingKey: string): Promise; +} + +/** + * Represents a signed transaction. + */ +declare interface SignedTx { + /** + * The type of the object, used for identification (the "SignedTx" string). + */ + objectType: string; + + /** + * Adds an extra signature to the transaction with a payment key. + * @param signingKey The signing key to witness the transaction. + * @returns The `SignedTx` object with the additional signature. + */ + alsoSignWithPaymentKey(signingKey: string): SignedTx; + + /** + * Converts the signed transaction to its CBOR representation. + * @returns A promise that resolves to the CBOR representation of the transaction as a hex string. + */ + txToCbor(): Promise; +} + +/** + * The main Cardano API object with static methods. + */ +declare interface CardanoAPI { + /** + * The type of the object, used for identification (the "CardanoAPI" string). + */ + objectType: string; + + /** + * Creates a new Conway-era transaction. + * @returns A promise that resolves to a new `UnsignedTx` object. + */ + newConwayTx(): Promise; +} + diff --git a/cardano-wasm/example/cardano-api.js b/cardano-wasm/lib-wrapper/cardano-api.js similarity index 93% rename from cardano-wasm/example/cardano-api.js rename to cardano-wasm/lib-wrapper/cardano-api.js index 91f0d96814..1440a18233 100644 --- a/cardano-wasm/example/cardano-api.js +++ b/cardano-wasm/lib-wrapper/cardano-api.js @@ -1,8 +1,10 @@ +/// + import { WASI } from "https://unpkg.com/@bjorn3/browser_wasi_shim@0.4.1/dist/index.js"; import ghc_wasm_jsffi from "./cardano-wasm.js"; const __exports = {}; const wasi = new WASI([], [], []); -async function initialize() { +async function initialise() { let { instance } = await WebAssembly.instantiateStreaming(fetch("./cardano-wasm.wasm"), { ghc_wasm_jsffi: ghc_wasm_jsffi(__exports), wasi_snapshot_preview1: wasi.wasiImport, @@ -12,7 +14,7 @@ async function initialize() { // Wrap a function with variable arguments to make the parameters inspectable function fixateArgs(params, func) { - const paramString = params.join(','); + const paramString = params.map(p => p.name).join(','); // Dynamically create a function that captures 'func' from the closure. // 'this' and 'arguments' are passed through from the wrapper to 'func'. // Using eval allows the returned function to have named parameters for inspectability. @@ -26,7 +28,7 @@ async function initialize() { // Same as fixateArgs but for async functions async function fixateArgsAsync(params, func) { - const paramString = params.join(','); + const paramString = params.map(p => p.name).join(','); // Dynamically create an async function. const wrapper = eval(` (async function(${paramString}) { @@ -80,7 +82,7 @@ async function initialize() { }); // Populate the main API object with static methods - apiInfo.staticMethods.forEach(method => { + apiInfo.mainObject.methods.forEach(method => { cardanoAPI[method.name] = async function (...args) { const resultPromise = instance.exports[method.name](...args); @@ -93,4 +95,4 @@ async function initialize() { }); return cardanoAPI; } -export default initialize; +export default initialise; diff --git a/cardano-wasm/src/Cardano/Wasm/Internal/Api/Info.hs b/cardano-wasm/src/Cardano/Wasm/Internal/Api/Info.hs index 7f79d34ee4..76a0e6af63 100644 --- a/cardano-wasm/src/Cardano/Wasm/Internal/Api/Info.hs +++ b/cardano-wasm/src/Cardano/Wasm/Internal/Api/Info.hs @@ -1,6 +1,14 @@ {-# LANGUAGE InstanceSigs #-} -module Cardano.Wasm.Internal.Api.Info (apiInfo) where +module Cardano.Wasm.Internal.Api.Info + ( apiInfo + , ApiInfo (..) + , VirtualObjectInfo (..) + , MethodInfo (..) + , ParamInfo (..) + , MethodReturnTypeInfo (..) + ) +where import Data.Aeson qualified as Aeson import Data.Text qualified as Text @@ -18,56 +26,98 @@ data MethodReturnTypeInfo deriving (Show, Eq) instance Aeson.ToJSON MethodReturnTypeInfo where + toJSON :: MethodReturnTypeInfo -> Aeson.Value toJSON Fluent = Aeson.object ["type" Aeson..= Text.pack "fluent"] toJSON (NewObject objTypeName) = Aeson.object ["type" Aeson..= Text.pack "newObject", "objectType" Aeson..= objTypeName] toJSON (OtherType typeName) = Aeson.object ["type" Aeson..= Text.pack "other", "typeName" Aeson..= typeName] +-- | Information about a single parameter of a method. +data ParamInfo = ParamInfo + { paramName :: String + -- ^ Name of the parameter. + , paramType :: String + -- ^ Type of the parameter (as a TypeScript type). + , paramDoc :: String + -- ^ Documentation for the parameter. + } + deriving (Show, Eq) + +instance Aeson.ToJSON ParamInfo where + toJSON :: ParamInfo -> Aeson.Value + toJSON (ParamInfo name pType doc) = + Aeson.object + [ "name" Aeson..= name + , "type" Aeson..= pType + , "doc" Aeson..= doc + ] + -- | Information about a single method of a virtual object. data MethodInfo = MethodInfo { methodName :: String - , methodParams :: [String] - -- ^ Names of parameters, excluding 'this'. + -- ^ Name of the method in the virtual object of the JS API (which should match the exported function). + , methodDoc :: String + -- ^ General documentation for the method. + , methodParams :: [ParamInfo] + -- ^ Info about parameters, excluding 'this'. , methodReturnType :: MethodReturnTypeInfo + -- ^ Return type of the method. + , methodReturnDoc :: String + -- ^ Documentation for the return value of the method. } deriving (Show, Eq) instance Aeson.ToJSON MethodInfo where toJSON :: MethodInfo -> Aeson.Value - toJSON (MethodInfo name params retType) = + toJSON (MethodInfo name doc params retType retDoc) = Aeson.object [ "name" Aeson..= name + , "doc" Aeson..= doc , "params" Aeson..= params , "return" Aeson..= retType + , "returnDoc" Aeson..= retDoc ] -- | Information about a virtual object and its methods. data VirtualObjectInfo = VirtualObjectInfo { virtualObjectName :: String + -- ^ Name of the virtual object. + , virtualObjectDoc :: String + -- ^ Documentation for the virtual object. , virtualObjectMethods :: [MethodInfo] + -- ^ Information about the methods of the virtual object. } deriving (Show, Eq) instance Aeson.ToJSON VirtualObjectInfo where toJSON :: VirtualObjectInfo -> Aeson.Value - toJSON (VirtualObjectInfo name methods) = + toJSON (VirtualObjectInfo name doc methods) = Aeson.object [ "objectName" Aeson..= name + , "doc" Aeson..= doc , "methods" Aeson..= methods ] -- | Aggregate type for all API information. data ApiInfo = ApiInfo - { staticMethods :: [MethodInfo] + { mainObject :: VirtualObjectInfo + -- ^ Information about the main virtual object of the API, which serves as the entry point. , virtualObjects :: [VirtualObjectInfo] + -- ^ Info about all other (non-main) virtual objects and their methods. + , initialiseFunctionDoc :: String + -- ^ Documentation for the initialise function of the API (which creates the main virtual object). + , initialiseFunctionReturnDoc :: String + -- ^ Documentation for the return value of the initialise function (which is the main virtual object). } deriving (Show, Eq) instance Aeson.ToJSON ApiInfo where toJSON :: ApiInfo -> Aeson.Value - toJSON (ApiInfo staticObjs virtualObjs) = + toJSON (ApiInfo mainObj virtualObjs initDoc initRetDoc) = Aeson.object - [ "staticMethods" Aeson..= staticObjs + [ "mainObject" Aeson..= mainObj , "virtualObjects" Aeson..= virtualObjs + , "initialiseFunctionDoc" Aeson..= initDoc + , "initialiseFunctionReturnDoc" Aeson..= initRetDoc ] -- | Provides metadata about the "virtual objects" and their methods. @@ -77,43 +127,59 @@ apiInfo = let unsignedTxObjectName = "UnsignedTx" signedTxObjectName = "SignedTx" - staticApiMethods = - [ MethodInfo - { methodName = "newConwayTx" - , methodParams = [] - , methodReturnType = NewObject unsignedTxObjectName - } - ] - unsignedTxObj = VirtualObjectInfo { virtualObjectName = unsignedTxObjectName + , virtualObjectDoc = "Represents an unsigned transaction." , virtualObjectMethods = [ MethodInfo { methodName = "addTxInput" - , methodParams = ["txId", "txIx"] + , methodDoc = "Adds a simple transaction input to the transaction." + , methodParams = + [ ParamInfo "txId" "string" "The transaction ID of the input UTxO." + , ParamInfo "txIx" "number" "The index of the input within the UTxO." + ] , methodReturnType = Fluent + , methodReturnDoc = "The `UnsignedTx` object with the added input." } , MethodInfo { methodName = "addSimpleTxOut" - , methodParams = ["destAddr", "lovelaceAmount"] + , methodDoc = "Adds a simple transaction output to the transaction." + , methodParams = + [ ParamInfo "destAddr" "string" "The destination address." + , ParamInfo "lovelaceAmount" "bigint" "The amount in lovelaces to output." + ] , methodReturnType = Fluent + , methodReturnDoc = "The `UnsignedTx` object with the added output." } , MethodInfo { methodName = "setFee" - , methodParams = ["lovelaceAmount"] + , methodDoc = "Sets the fee for the transaction." + , methodParams = [ParamInfo "lovelaceAmount" "bigint" "The fee amount in lovelaces."] , methodReturnType = Fluent - } - , MethodInfo - { methodName = "signWithPaymentKey" - , methodParams = ["signingKey"] - , methodReturnType = NewObject signedTxObjectName + , methodReturnDoc = "The `UnsignedTx` object with the set fee." } , MethodInfo { methodName = "estimateMinFee" + , methodDoc = "Estimates the minimum fee for the transaction." , methodParams = - ["protocolParams", "numExtraKeyWitnesses", "numExtraByronKeyWitnesses", "totalRefScriptSize"] + [ ParamInfo "protocolParams" "any" "The protocol parameters." + , ParamInfo + "numKeyWitnesses" + "number" + "The number of key witnesses." + , ParamInfo "numByronKeyWitnesses" "number" "The number of Byron key witnesses." + , ParamInfo "totalRefScriptSize" "number" "The total size of reference scripts in bytes." + ] , methodReturnType = OtherType "BigInt" + , methodReturnDoc = "A promise that resolves to the estimated minimum fee in lovelaces." + } + , MethodInfo + { methodName = "signWithPaymentKey" + , methodDoc = "Signs the transaction with a payment key." + , methodParams = [ParamInfo "signingKey" "string" "The signing key to witness the transaction."] + , methodReturnType = NewObject signedTxObjectName + , methodReturnDoc = "A promise that resolves to a `SignedTx` object." } ] } @@ -121,20 +187,41 @@ apiInfo = signedTxObj = VirtualObjectInfo { virtualObjectName = signedTxObjectName + , virtualObjectDoc = "Represents a signed transaction." , virtualObjectMethods = [ MethodInfo { methodName = "alsoSignWithPaymentKey" - , methodParams = ["signingKey"] + , methodDoc = "Adds an extra signature to the transaction with a payment key." + , methodParams = [ParamInfo "signingKey" "string" "The signing key to witness the transaction."] , methodReturnType = Fluent + , methodReturnDoc = "The `SignedTx` object with the additional signature." } , MethodInfo { methodName = "txToCbor" + , methodDoc = "Converts the signed transaction to its CBOR representation." , methodParams = [] , methodReturnType = OtherType "string" + , methodReturnDoc = + "A promise that resolves to the CBOR representation of the transaction as a hex string." } ] } in ApiInfo - { staticMethods = staticApiMethods + { mainObject = + VirtualObjectInfo + { virtualObjectName = "CardanoAPI" + , virtualObjectDoc = "The main Cardano API object with static methods." + , virtualObjectMethods = + [ MethodInfo + { methodName = "newConwayTx" + , methodDoc = "Creates a new Conway-era transaction." + , methodParams = [] + , methodReturnType = NewObject unsignedTxObjectName + , methodReturnDoc = "A promise that resolves to a new `UnsignedTx` object." + } + ] + } , virtualObjects = [unsignedTxObj, signedTxObj] + , initialiseFunctionDoc = "Initialises the Cardano API." + , initialiseFunctionReturnDoc = "A promise that resolves to the main `CardanoAPI` object." } diff --git a/cardano-wasm/src/Cardano/Wasm/Internal/Api/InfoToTypeScript.hs b/cardano-wasm/src/Cardano/Wasm/Internal/Api/InfoToTypeScript.hs new file mode 100644 index 0000000000..043356c833 --- /dev/null +++ b/cardano-wasm/src/Cardano/Wasm/Internal/Api/InfoToTypeScript.hs @@ -0,0 +1,72 @@ +module Cardano.Wasm.Internal.Api.InfoToTypeScript where + +import Cardano.Wasm.Internal.Api.Info qualified as Info +import Cardano.Wasm.Internal.Api.TypeScriptDefs qualified as TypeScript + +-- | Converts the Cardano API information to a TypeScript declaration file AST. +apiInfoToTypeScriptFile :: Info.ApiInfo -> TypeScript.TypeScriptFile +apiInfoToTypeScriptFile apiInfo = + TypeScript.TypeScriptFile + { TypeScript.typeScriptFileName = "cardano-api.d.ts" + , TypeScript.typeScriptFileContent = + [ TypeScript.Declaration [] (TypeScript.ExportDec True "initialise") + , TypeScript.Declaration + [ Info.initialiseFunctionDoc apiInfo + , "@returns " <> Info.initialiseFunctionReturnDoc apiInfo + ] + ( TypeScript.FunctionDec $ + TypeScript.FunctionHeader + { TypeScript.functionName = "initialise" + , TypeScript.functionParams = [] + , TypeScript.functionReturnType = + "Promise<" <> Info.virtualObjectName (Info.mainObject apiInfo) <> ">" + } + ) + ] + <> virtualObjectInterfaces + } + where + virtualObjectInterfaces = + map virtualObjectInfoToInterfaceDec (Info.virtualObjects apiInfo <> [Info.mainObject apiInfo]) + +virtualObjectInfoToInterfaceDec :: Info.VirtualObjectInfo -> TypeScript.Declaration +virtualObjectInfoToInterfaceDec vo = + TypeScript.Declaration + [Info.virtualObjectDoc vo] + ( TypeScript.InterfaceDec + (Info.virtualObjectName vo) + ( [ TypeScript.InterfaceContent + [ "The type of the object, used for identification (the \"" + <> Info.virtualObjectName vo + <> "\" string)." + ] + (TypeScript.InterfaceProperty "objectType" "string") + ] + <> map (methodInfoToInterfaceContent (Info.virtualObjectName vo)) (Info.virtualObjectMethods vo) + ) + ) + +methodInfoToInterfaceContent :: String -> Info.MethodInfo -> TypeScript.InterfaceContent +methodInfoToInterfaceContent selfTypeName method = + TypeScript.InterfaceContent + ( [Info.methodDoc method] + <> map (\p -> "@param " <> Info.paramName p <> " " <> Info.paramDoc p) (Info.methodParams method) + <> ["@returns " <> Info.methodReturnDoc method] + ) + ( TypeScript.InterfaceMethod + (Info.methodName method) + (map paramInfoToFunctionParam $ Info.methodParams method) + (methodReturnTypeToString selfTypeName $ Info.methodReturnType method) + ) + +paramInfoToFunctionParam :: Info.ParamInfo -> TypeScript.FunctionParam +paramInfoToFunctionParam p = + TypeScript.FunctionParam + { TypeScript.paramName = Info.paramName p + , TypeScript.paramType = Info.paramType p + } + +methodReturnTypeToString :: String -> Info.MethodReturnTypeInfo -> String +methodReturnTypeToString selfTypeName Info.Fluent = selfTypeName +methodReturnTypeToString _ (Info.NewObject objTypeName) = "Promise<" <> objTypeName <> ">" +methodReturnTypeToString _ (Info.OtherType typeName) = "Promise<" <> typeName <> ">" diff --git a/cardano-wasm/src/Cardano/Wasm/Internal/Api/Tx.hs b/cardano-wasm/src/Cardano/Wasm/Internal/Api/Tx.hs index 3d54c42502..55999b9c49 100644 --- a/cardano-wasm/src/Cardano/Wasm/Internal/Api/Tx.hs +++ b/cardano-wasm/src/Cardano/Wasm/Internal/Api/Tx.hs @@ -45,9 +45,9 @@ import Lens.Micro ((%~), (&), (.~), (<>~)) -- * @UnsignedTx@ object --- | An object representing a transaction that is being built and hasn't +-- | An object representing a transaction that is being built and has not -- been signed yet. It abstracts over the era of the transaction. --- It is meant to be an opaque object in JavaScript API. +-- It is meant to be an opaque object in the JavaScript API. data UnsignedTxObject = forall era. UnsignedTxObject { unsignedTxEra :: Exp.Era era @@ -88,7 +88,7 @@ addTxInputImpl (UnsignedTxObject era (Exp.UnsignedTx tx)) txId txIx = in UnsignedTxObject era $ Exp.UnsignedTx tx' -- | Add a simple transaction output to an unsigned transaction object. --- It takes a destination address and an amount in lovelace. +-- It takes a destination address and an amount in lovelaces. addSimpleTxOutImpl :: (HasCallStack, MonadThrow m) => UnsignedTxObject -> String -> Ledger.Coin -> m UnsignedTxObject addSimpleTxOutImpl (UnsignedTxObject era (Exp.UnsignedTx tx)) destAddr lovelaceAmount = @@ -148,7 +148,7 @@ estimateMinFeeImpl => UnsignedTxObject -- ^ The unsigned transaction object to estimate fees for. -> ProtocolParamsJSON - -- ^ The JSON for the protocol parameters of the right era and network. + -- ^ The JSON for the protocol parameters of the correct era and network. -> Int -- ^ The number of key witnesses still to be added to the transaction. -> Int diff --git a/cardano-wasm/src/Cardano/Wasm/Internal/Api/TypeScriptDefs.hs b/cardano-wasm/src/Cardano/Wasm/Internal/Api/TypeScriptDefs.hs new file mode 100644 index 0000000000..1e034f38cb --- /dev/null +++ b/cardano-wasm/src/Cardano/Wasm/Internal/Api/TypeScriptDefs.hs @@ -0,0 +1,190 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | This module defines a basic AST for generating TypeScript +-- declaration files, and basic pretty-printing functionality. +-- +-- The reason we define a custom tool for generating TypeScript +-- declaration files is that existing libraries like `aeson-typescript` +-- and `servant-typescript` are not aimed at generating custom +-- TypeScript interface declaration files for a specific API, +-- but rather at generating TypeScript interfaces for Haskell +-- data types and servant HTTP APIs respectively. And other libraries +-- that align with our needs, like `language-typescript`, are not +-- actively maintained. +module Cardano.Wasm.Internal.Api.TypeScriptDefs where + +import Data.List.NonEmpty qualified as LNE +import Data.Text.Lazy qualified as TL +import Data.Text.Lazy.Builder qualified as TLB + +-- | Prints the TypeScript declaration file to stdout. +printTypeScriptFile :: TypeScriptFile -> IO () +printTypeScriptFile tsFile = do + let content = buildTypeScriptFile tsFile + putStrLn $ TL.unpack $ TLB.toLazyText content + +-- | Creates a builder for a JavaScript-style multiline comment +-- with the specified indentation level (in spaces) and each line +-- in the list of strings as a separate line in the comment. +-- The first line starts with `/**`, subsequent lines (corresponding +-- to each line in the list) start with ` * `, +-- and the last line ends with ` */`. +-- The indentation level is used to indent the entire comment block. +buildMultilineComment :: Int -> [String] -> TLB.Builder +buildMultilineComment indentLevel commentLines = + let indentation = TLB.fromLazyText $ TL.replicate (fromIntegral indentLevel) " " + bodyIndentation = indentation <> " * " + firstLine = indentation <> "/**" + indentedCommentLines = map (\line -> (bodyIndentation <>) . TLB.fromString $ line <> "\n") commentLines + lastLine = indentation <> " */" + in mconcat [firstLine, "\n", mconcat indentedCommentLines, lastLine] + +-- | Represents the top-level structure of a TypeScript declaration file. +data TypeScriptFile = TypeScriptFile + { typeScriptFileName :: String + -- ^ Name of the TypeScript file. + , typeScriptFileContent :: [Declaration] + -- ^ List of declarations in the file. + } + +-- | Creates a builder for a TypeScript declaration file. +-- It adds a comment to the top of the file with the file name. +buildTypeScriptFile :: TypeScriptFile -> TLB.Builder +buildTypeScriptFile (TypeScriptFile name decls) = + let header = TLB.fromString $ "// " ++ name ++ "\n" + declarations = mconcat $ map (\dec -> "\n" <> buildDeclaration dec <> "\n") decls + in header <> declarations + +-- | Wraps a TypeScript declaration with a comment. +-- The TypeScript declaration can have any of the types +-- defined by 'DeclarationType'. +data Declaration = Declaration + { declarationComment :: [String] + -- ^ Comments for the declaration, can be empty if no comments are needed. + -- Each element in the list is a separate line in the comment. + , declarationContent :: DeclarationType + -- ^ The type and content of the declaration. + } + +-- | Creates a builder for a TypeScript declaration. +buildDeclaration :: Declaration -> TLB.Builder +buildDeclaration (Declaration [] declarationType) = buildDeclarationType declarationType +buildDeclaration (Declaration comments declarationType) = + buildMultilineComment 0 comments <> "\n" <> buildDeclarationType declarationType + +-- | Represents a TypeScript declaration content of some type. +data DeclarationType + = -- | Export declaration. + ExportDec + Bool + -- ^ Is it a default export? + String + -- ^ Name of the symbol to export. + | -- | Function declaration. + FunctionDec FunctionHeader + | -- | Interface declaration. + InterfaceDec + String + -- ^ Name of the interface. + [InterfaceContent] + -- ^ Definitions of the interface. + +-- | Creates a builder for a TypeScript declaration type and content. +buildDeclarationType :: DeclarationType -> TLB.Builder +buildDeclarationType (ExportDec isDefault symbolName) = + "export " + <> (if isDefault then "default " else "") + <> TLB.fromString symbolName + <> ";" +buildDeclarationType (FunctionDec header) = + "declare function " <> buildFunctionHeader header <> ";" +buildDeclarationType (InterfaceDec name properties) = + "declare interface " + <> TLB.fromString name + <> " {" + <> mconcat (map (\prop -> "\n" <> buildInterfaceContent prop <> "\n") properties) + <> "}" + +-- | Represents a function parameter in TypeScript. +data FunctionParam = FunctionParam + { paramName :: String + -- ^ Name of the parameter. + , paramType :: String + -- ^ Type of the parameter. + } + +-- | Creates a builder for a TypeScript function parameter. +buildFunctionParam :: FunctionParam -> TLB.Builder +buildFunctionParam (FunctionParam name pType) = + TLB.fromString name <> ": " <> TLB.fromString pType + +-- | Represents a TypeScript function header. +data FunctionHeader = FunctionHeader + { functionName :: String + -- ^ Name of the function. + , functionParams :: [FunctionParam] + -- ^ List of parameters of the function. + , functionReturnType :: String + -- ^ Return type of the function. + } + +-- | Creates a builder for a TypeScript function header. +buildFunctionHeader :: FunctionHeader -> TLB.Builder +buildFunctionHeader (FunctionHeader name params returnType) = + TLB.fromString name + <> "(" + <> mconcatWith ", " (map buildFunctionParam params) + <> "): " + <> TLB.fromString returnType + +-- | Represents a TypeScript interface content of some type +-- out of the ones defined by 'InterfaceContentType'. +data InterfaceContent = InterfaceContent + { interfaceContentComment :: [String] + -- ^ Comments for the interface content. + , interfaceContentValue :: InterfaceContentType + -- ^ The type and content of the interface. + } + +-- | Creates a builder for a TypeScript interface content. +buildInterfaceContent :: InterfaceContent -> TLB.Builder +buildInterfaceContent (InterfaceContent [] interfaceType) = buildInterfaceContentType interfaceType +buildInterfaceContent (InterfaceContent comments interfaceType) = + let indentationAmount = 4 + indentation = TLB.fromLazyText $ TL.replicate (fromIntegral indentationAmount) " " + comment = buildMultilineComment indentationAmount comments + in comment <> "\n" <> indentation <> buildInterfaceContentType interfaceType + +-- | Represents a TypeScript interface type and content. +data InterfaceContentType + = -- | Defines a property in the interface. + InterfaceProperty + String + -- ^ Property name. + String + -- ^ Property type. + | -- | Defines a method in the interface. + InterfaceMethod + String + -- ^ Method name. + [FunctionParam] + -- ^ Method parameters. + String + -- ^ Return type of the method. + +-- | Creates a builder for a TypeScript interface content and type. +buildInterfaceContentType :: InterfaceContentType -> TLB.Builder +buildInterfaceContentType (InterfaceProperty name pType) = + TLB.fromString name <> ": " <> TLB.fromString pType <> ";" +buildInterfaceContentType (InterfaceMethod name params returnType) = + TLB.fromString name + <> "(" + <> mconcatWith ", " (map buildFunctionParam params) + <> "): " + <> TLB.fromString returnType + <> ";" + +-- | Concatenates a list of builders with a separator. +-- If the list is empty, it returns an empty builder. +mconcatWith :: TLB.Builder -> [TLB.Builder] -> TLB.Builder +mconcatWith separator = maybe mempty (foldr1 (\a b -> a <> separator <> b)) . LNE.nonEmpty diff --git a/cardano-wasm/test/cardano-wasm-golden/Test/Golden/Cardano/Wasm/TypeScript.hs b/cardano-wasm/test/cardano-wasm-golden/Test/Golden/Cardano/Wasm/TypeScript.hs new file mode 100644 index 0000000000..0248839921 --- /dev/null +++ b/cardano-wasm/test/cardano-wasm-golden/Test/Golden/Cardano/Wasm/TypeScript.hs @@ -0,0 +1,10 @@ +module Test.Golden.Cardano.Wasm.TypeScript where + +import Hedgehog as H +import Hedgehog.Extras qualified as H + +hprop_cardano_wasm_typescript_declarations_match_generated :: Property +hprop_cardano_wasm_typescript_declarations_match_generated = + H.propertyOnce $ do + result <- H.execFlex "cardano-wasm" "CARDANO_WASM" [] + H.diffVsGoldenFile result "lib-wrapper/cardano-api.d.ts" diff --git a/cardano-wasm/test/cardano-wasm-golden/cardano-wasm-golden.hs b/cardano-wasm/test/cardano-wasm-golden/cardano-wasm-golden.hs new file mode 100644 index 0000000000..de4594616b --- /dev/null +++ b/cardano-wasm/test/cardano-wasm-golden/cardano-wasm-golden.hs @@ -0,0 +1,8 @@ +{-# LANGUAGE CPP #-} + +#if !defined(wasm32_HOST_ARCH) +{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --hide-successes #-} +#else +main = return () +#endif + diff --git a/flake.lock b/flake.lock index 8db53f7699..3b72672e95 100644 --- a/flake.lock +++ b/flake.lock @@ -565,6 +565,24 @@ "type": "github" } }, + "incl": { + "inputs": { + "nixlib": "nixlib" + }, + "locked": { + "lastModified": 1693483555, + "narHash": "sha256-Beq4WhSeH3jRTZgC1XopTSU10yLpK1nmMcnGoXO0XYo=", + "owner": "divnix", + "repo": "incl", + "rev": "526751ad3d1e23b07944b14e3f6b7a5948d3007b", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "incl", + "type": "github" + } + }, "iohkNix": { "inputs": { "blst": "blst", @@ -603,6 +621,21 @@ "type": "github" } }, + "nixlib": { + "locked": { + "lastModified": 1667696192, + "narHash": "sha256-hOdbIhnpWvtmVynKcsj10nxz9WROjZja+1wRAJ/C9+s=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "babd9cd2ca6e413372ed59fbb1ecc3c3a5fd3e5b", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1744098102, @@ -772,6 +805,7 @@ "ghc-wasm-meta": "ghc-wasm-meta", "hackageNix": "hackageNix", "haskellNix": "haskellNix", + "incl": "incl", "iohkNix": "iohkNix", "nixpkgs": "nixpkgs_3", "wasm-nixpkgs": [ diff --git a/flake.nix b/flake.nix index 2a394098b2..59567ac51e 100644 --- a/flake.nix +++ b/flake.nix @@ -16,6 +16,7 @@ nixpkgs.url = "github:NixOS/nixpkgs/4284c2b73c8bce4b46a6adf23e16d9e2ec8da4bb"; iohkNix.url = "github:input-output-hk/iohk-nix"; flake-utils.url = "github:hamishmack/flake-utils/hkm/nested-hydraJobs"; + incl.url = "github:divnix/incl"; # non-flake nix compatibility flake-compat = { url = "github:edolstra/flake-compat"; @@ -137,7 +138,7 @@ # package customizations as needed. Where cabal.project is not # specific enough, or doesn't allow setting these. modules = [ - ({pkgs, ...}: { + ({...}: { packages.cardano-api = { configureFlags = ["--ghc-option=-Werror"]; components = { @@ -149,6 +150,17 @@ }; }; }) + ({pkgs, config, ...}: let + generatedExampleFiles = ["cardano-wasm/lib-wrapper/cardano-api.d.ts"]; + exportWasmPath = "export CARDANO_WASM=${config.hsPkgs.cardano-wasm.components.exes.cardano-wasm}/bin/cardano-wasm${pkgs.stdenv.hostPlatform.extensions.executable}"; + in { + packages.cardano-wasm.components.tests.cardano-wasm-golden.preCheck = let + filteredProjectBase = inputs.incl ./. generatedExampleFiles; + in '' + ${exportWasmPath} + cp -r ${filteredProjectBase}/* .. + ''; + }) { packages.crypton-x509-system.postPatch = '' substituteInPlace crypton-x509-system.cabal --replace 'Crypt32' 'crypt32'