diff --git a/README.md b/README.md index 6be581f..a6e3a92 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,30 @@ - # Stellar-Plus +

npm version Weekly Downloads

-stellar-plus is an all-in-one Javascript library for building and interacting with the Stellar network. It bundles the main resources from the community into an easy-to-use set of tools and capabilities. -It provides: +
+ + + + +
+
+ +Stellar-plus is a robust JavaScript library built by [Cheesecake Labs](./) and designed to streamline the development of applications on the Stellar network. By integrating the Stellar community's primary resources, Stellar-plus offers developers an efficient, easy-to-use toolkit. This library simplifies the complexities of Stellar network interaction, making it accessible for both novice and experienced developers alike. -* **Account**: Handlers to create, load, and interact with stellar accounts, managing signatures and automatically integrating with Freighter Wallet for web applications. -* **Asset**: Classic token handlers follow the standard token interface for triggering different asset capabilities as well as a suite of additional features for asset management and usage. -* **Core**: Key engines for managing the different pipelines for building, submitting, and processing both Classic and Soroban transactions. These engines can be extended into your own tooling or used out-of-the-box with minimal configuration. -* **Contracts**: Default contract client implementations for selected dApp use cases. -* **RPC**: Handlers for connecting and using different RPC solutions, including a ready-to-use integration with Validation Cloud's RPC API. +## Features + +- **Account Handling**: Seamless management of signatures throughout the transaction lifecycle. +- **Asset Management**: Full suite of asset management capabilities, including standard and custom assets. +- **Core Engines**: Essential for building, submitting, signing, and processing transactions on the Stellar network. +- **Contract Development**: Simplifies the development of decentralized applications (dApps). +- **RPC Integration**: Connects to and leverages various RPC services for a broader range of applications. +- **Plugins and Extensions**: Supports plugins and tools to enhance functionality and tailor the library to specific needs. ## Quick start @@ -35,15 +45,19 @@ npm install --save stellar-plus require/import it in your JavaScript: ```js -var StellarPlus = require("stellar-plus"); +var StellarPlus = require('stellar-plus') ``` or ```js -import { StellarPlus } from "stellar-plus"; +import { StellarPlus } from 'stellar-plus' ``` ## Documentation For the full documentation, refer to our [Gitbook Documentation](https://cheesecake-labs.gitbook.io/stellar-plus/?utm_source=github&utm_medium=codigo-fonte). + +- [Code of Conduct](https://github.com/cheesecakelabs/stellar-plus/blob/main/CODE_OF_CONDUCT.md) +- [Contributing Guidelines](https://github.com/cheesecakelabs/stellar-plus/blob/main/CONTRIBUTING.md) +- [MIT License](https://github.com/cheesecakelabs/stellar-plus/blob/main/LICENSE) diff --git a/package-lock.json b/package-lock.json index 5400609..25c8849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "eslint-plugin-unicorn": "^49.0.0", "husky": "^8.0.0", "jest": "^29.7.0", + "jest-extended": "^4.0.2", "lint-staged": "^15.2.2", "nodemon": "^3.0.1", "prettier": "^3.1.0", @@ -6041,6 +6042,27 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", + "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", + "dev": true, + "dependencies": { + "jest-diff": "^29.0.0", + "jest-get-type": "^29.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + } + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -13623,6 +13645,16 @@ "jest-util": "^29.7.0" } }, + "jest-extended": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-4.0.2.tgz", + "integrity": "sha512-FH7aaPgtGYHc9mRjriS0ZEHYM5/W69tLrFTIdzm+yJgeoCmmrSB/luSfMSqWP9O29QWHPEmJ4qmU6EwsZideog==", + "dev": true, + "requires": { + "jest-diff": "^29.0.0", + "jest-get-type": "^29.0.0" + } + }, "jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", diff --git a/package.json b/package.json index 63087f5..3f3bcb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stellar-plus", - "version": "0.6.2", + "version": "0.7.0", "description": "beta version of stellar-plus, an all-in-one sdk for the Stellar blockchain", "main": "./lib/index.js", "types": "./lib/index.d.ts", @@ -11,8 +11,8 @@ "scripts": { "build": "tsc && tsc-alias -p tsconfig.json", "dev": "nodemon -e ts,js --exec ts-node -r tsconfig-paths/register ./src/index-test.ts", - "test": "npx jest --config src/jest.config.ts", - "coverage": "npx jest --config src/jest.config.ts --coverage", + "test": "npx jest --config src/jest.config.js", + "coverage": "npx jest --config src/jest.config.js --coverage", "lint": "eslint --ext .js,.ts .", "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json)\"", "prepare": "husky install" @@ -49,6 +49,7 @@ "eslint-plugin-unicorn": "^49.0.0", "husky": "^8.0.0", "jest": "^29.7.0", + "jest-extended": "^4.0.2", "lint-staged": "^15.2.2", "nodemon": "^3.0.1", "prettier": "^3.1.0", diff --git a/src/jest.config.js b/src/jest.config.js new file mode 100644 index 0000000..be21348 --- /dev/null +++ b/src/jest.config.js @@ -0,0 +1,13 @@ +// eslint-disable-next-line no-undef +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true, + coverageReporters: ['cobertura', 'json', 'html', 'text'], + coveragePathIgnorePatterns: ['/node_modules/', '/*/.*\\.types.ts', '.*\\mocks.ts', './coverage/'], + collectCoverageFrom: ['./**/*.ts'], + setupFilesAfterEnv: ['./setup-tests.ts'], + modulePathIgnorePatterns: ['/dist/'], + moduleDirectories: ['node_modules', 'src'], + rootDir: './', +} diff --git a/src/jest.config.ts b/src/jest.config.ts deleted file mode 100644 index 21f59ca..0000000 --- a/src/jest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Config } from '@jest/types' -import { pathsToModuleNameMapper } from 'ts-jest' - -const config: Config.InitialOptions = { - preset: 'ts-jest', - testEnvironment: 'node', - verbose: false, - automock: false, - moduleDirectories: ['node_modules', 'src'] -} -export default config diff --git a/src/setup-tests.ts b/src/setup-tests.ts new file mode 100644 index 0000000..5829630 --- /dev/null +++ b/src/setup-tests.ts @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import * as matchers from 'jest-extended' +expect.extend(matchers) + +jest.setTimeout(10000) +process.env.DEBUG = 'false' diff --git a/src/stellar-plus/account/account-handler/freighter/index.ts b/src/stellar-plus/account/account-handler/freighter/index.ts index eeafe1e..843c1ee 100644 --- a/src/stellar-plus/account/account-handler/freighter/index.ts +++ b/src/stellar-plus/account/account-handler/freighter/index.ts @@ -128,7 +128,7 @@ export class FreighterAccountHandlerClient extends AccountBaseClient implements * * @param {xdr.SorobanAuthorizationEntry} entry - The soroban authorization entry to sign. * @param {number} validUntilLedgerSeq - The ledger sequence number until which the entry signature is valid. - * + * @param {string} networkPassphrase - The network passphrase for the network to sign the entry for. * @description - Signs the given Soroban authorization entry with the account's secret key. * * @returns {xdr.SorobanAuthorizationEntry} The signed entry. diff --git a/src/stellar-plus/account/base/index.ts b/src/stellar-plus/account/base/index.ts index 8d01caf..56b4b8d 100644 --- a/src/stellar-plus/account/base/index.ts +++ b/src/stellar-plus/account/base/index.ts @@ -8,7 +8,7 @@ export class AccountBaseClient extends AccountHelpers implements AccountBase { * * @args {} payload - The payload for the account. Additional parameters may be provided to enable different helpers. * @param {string} payload.publicKey The public key of the account. - * @param {NetworkConfig=} payload.networkConfig The network to use. + * @param {NetworkConfig=} payload.networkConfig The network config for the target network. * * @description - The base account is used for handling accounts with no management actions. */ diff --git a/src/stellar-plus/asset/classic/index.ts b/src/stellar-plus/asset/classic/index.ts index fa29cfb..cad5560 100644 --- a/src/stellar-plus/asset/classic/index.ts +++ b/src/stellar-plus/asset/classic/index.ts @@ -26,10 +26,10 @@ export class ClassicAssetHandler implements IClassicAssetHandler { /** * * @param {string} code - The asset code. - * @param {string} issuerPublicKey - The public key of the asset issuer. - * @param {NetworkConfig} networkConfig - The network to use. - * @param {AccountHandler=} issuerAccount - The issuer account handler. When provided, it'll enable management functions and be used to sign transactions as the issuer. - * @param {TransactionSubmitter=} transactionSubmitter - The transaction submitter to use. + * @param {string | AccountHandler} issuerAccount - The issuer account. When an account handler is provided, it'll enable management functions and be used to sign transactions as the issuer. + * @param {NetworkConfig} networkConfig - The network configuration to use. + * @param {ClassicTransactionPipelineOptions} options - The options for the classic transaction pipeline. + @param {ClassicTransactionPipelineOptions} options.classicTransactionPipeline - The options for the classic transaction pipeline. These allow for custom configurations for how the transaction pipeline will operate for this asset. * * @description - The Classic asset handler is used for handling classic assets with user-based and management functionalities. * @@ -92,9 +92,9 @@ export class ClassicAssetHandler implements IClassicAssetHandler { return this.code } - // /** - // * @description - Not implemented in pure classic assets. Only available for Soroban assets. - // */ + /** + * @description - Not implemented in the current version for pure classic assets. Only available for Soroban assets. + */ public async approve(): Promise { throw new Error('Method not implemented.') @@ -186,10 +186,10 @@ export class ClassicAssetHandler implements IClassicAssetHandler { // /** - * + * @args * @param {string} to - The account id to mint the asset to. * @param {i128} amount - The amount of the asset to mint. - * @param {TransactionInvocation} txInvocation - The transaction invocation object. The Issuer account will be automatically added as a signer. + * @param {TransactionInvocation} txInvocation - The transaction invocation object spread. The Issuer account will be automatically added as a signer. * * @description - Mints the given amount of the asset to the 'to' account. * @requires - The issuer account to be set in the asset. @@ -241,7 +241,7 @@ export class ClassicAssetHandler implements IClassicAssetHandler { * * @param {string} to - The account id to mint the asset to. * @param {number} amount - The amount of the asset to mint. - * @param {TransactionInvocation} txInvocation - The transaction invocation object. The The Issuer account will be automatically added as a signer. + * @param {TransactionInvocation} txInvocation - The transaction invocation object spread. The Issuer account will be automatically added as a signer. * * @requires - The issuer account to be set in the asset. * @requires - The 'to' account to be set as a signer in the transaction invocation. @@ -289,7 +289,7 @@ export class ClassicAssetHandler implements IClassicAssetHandler { /** * * @param {string} to - The account id to add the trustline. - * @param {TransactionInvocation} txInvocation - The transaction invocation object. + * @param {TransactionInvocation} txInvocation - The transaction invocation object spread. * * @requires - The 'to' account to be set as a signer in the transaction invocation. * diff --git a/src/stellar-plus/asset/soroban-token/index.ts b/src/stellar-plus/asset/soroban-token/index.ts index fce35d2..292a838 100644 --- a/src/stellar-plus/asset/soroban-token/index.ts +++ b/src/stellar-plus/asset/soroban-token/index.ts @@ -14,14 +14,19 @@ export class SorobanTokenHandler extends ContractEngine implements SorobanTokenI * * @args args * @param {NetworkConfig} args.networkConfig - Network to connect to - * @param {ContractSpec=} args.spec - Contract specification object - * @param {string=} args.contractId - Contract ID - * @param {RpcHandler=} args.rpcHandler - RPC Handler + * @param args.contractParameters - Contract parameters + * @param {ContractSpec=} args.contractParameters.spec - Contract specification + * @param {string=} args.contractParameters.contractId - Contract ID * @param {Buffer=} args.wasm - Contract WASM file as Buffer * @param {string=} args.wasmHash - Contract WASM hash identifier + * @param {Options=} args.options - Contract options + * @param {SorobanTransactionPipelineOptions=} args.options.sorobanTransactionPipeline - Soroban transaction pipeline options. Allows for customizing how transaction pipeline will be executed for this contract. * * @description Create a new SorobanTokenHandler instance to interact with a Soroban Token contract. * This class is a subclass of ContractEngine and implements the Soroban token interface. + * The contract spec is set to the default Soroban Token spec. When initializing the contract, the spec can be overridden with a custom spec. + * The contract ID, WASM file, and WASM hash can be provided to initialize the contract with the given parameters. At least one of these parameters must be provided. + * */ constructor(args: SorobanTokenHandlerConstructorArgs) { super({ diff --git a/src/stellar-plus/asset/stellar-asset-contract/index.ts b/src/stellar-plus/asset/stellar-asset-contract/index.ts index 9fff1f8..238683a 100644 --- a/src/stellar-plus/asset/stellar-asset-contract/index.ts +++ b/src/stellar-plus/asset/stellar-asset-contract/index.ts @@ -6,6 +6,7 @@ import { SorobanTokenHandler } from 'stellar-plus/asset/soroban-token' import { SorobanTokenHandlerConstructorArgs } from 'stellar-plus/asset/soroban-token/types' import { SACConstructorArgs, SACHandler as SACHandlerType } from 'stellar-plus/asset/stellar-asset-contract/types' import { AssetTypes } from 'stellar-plus/asset/types' + import { TransactionInvocation } from 'stellar-plus/core/types' export class SACHandler implements SACHandlerType { @@ -20,15 +21,15 @@ export class SACHandler implements SACHandlerType { * @param {NetworkConfig} args.networkConfig - The network to connect to. * Parameters related to the classic asset. * @param {string} args.code - The asset code. - * @param {string} args.issuerPublicKey - The issuer public key. - * @param {AccountHandler=} args.issuerAccount - The issuer account. - * @param {TransactionSubmitter=} args.transactionSubmitter - The classic transaction submitter. - * Parameters related to the Soroban token. - * @param {ContractSpec=} args.spec - The contract specification object. - * @param {Buffer=} args.wasm - The contract wasm file as a buffer. - * @param {string=} args.wasmHash - The contract wasm hash id. - * @param {string=} args.contractId - The contract id. - * @param {RpcHandler=} args.rpcHandler - A custom Soroban RPC handler. + * @param {string | AccountHandler} args.issuerAccount - The issuer account. Can be a public key or an account handler. If it's an account handler, it will enable management functions. + * @param contractParameters - The contract parameters. + * @param {ContractSpec=} contractParameters.spec - The contract specification object. + * @param {Buffer=} contractParameters.wasm - The contract wasm file as a buffer. + * @param {string=} contractParameters.wasmHash - The contract wasm hash id. + * @param {string=} contractParameters.contractId - The contract id. + * @param options - The contract options. + * @param {SorobanTransactionPipelineOptions=} options.sorobanTransactionPipeline - The Soroban transaction pipeline. + * @param { ClassicTransactionPipelineOptions=} options.classicTransactionPipeline - The classic transaction pipeline. * * * @description - The Stellar Asset Contract handler. It combines the classic asset handler and the Soroban token handler. diff --git a/src/stellar-plus/core/contract-engine/errors.ts b/src/stellar-plus/core/contract-engine/errors.ts index b5dd4f7..d9f041d 100644 --- a/src/stellar-plus/core/contract-engine/errors.ts +++ b/src/stellar-plus/core/contract-engine/errors.ts @@ -17,18 +17,12 @@ export enum ContractEngineErrorCodes { CE006 = 'CE006', CE007 = 'CE007', CE008 = 'CE008', + CE009 = 'CE009', - // CE1 Simulation - CE100 = 'CE100', + // CE1 Meta CE101 = 'CE101', CE102 = 'CE102', CE103 = 'CE103', - - // CE2 Meta - CE200 = 'CE200', - CE201 = 'CE201', - CE202 = 'CE202', - CE203 = 'CE203', } const missingContractId = (): StellarPlusError => { @@ -119,71 +113,21 @@ const contractCodeMissingLiveUntilLedgerSeq = ( }) } -const simulationFailed = (simulation: SorobanRpc.Api.SimulateTransactionErrorResponse): StellarPlusError => { - return new StellarPlusError({ - code: ContractEngineErrorCodes.CE101, - message: 'Transaction simulation failed!', - source: 'ContractEngine', - details: - 'Transaction simulation failed! The transaction simulation returned a failure status. Review the meta data for further information about this error.', - meta: { - sorobanSimulationData: extractSimulationErrorData(simulation), - data: { simulation }, - }, - }) -} - -const simulationMissingResult = (simulation: SorobanRpc.Api.SimulateTransactionSuccessResponse): StellarPlusError => { - return new StellarPlusError({ - code: ContractEngineErrorCodes.CE103, - message: 'Transaction simulation is missing the result data!', - source: 'ContractEngine', - details: - 'Transaction simulation is missing the result data! The transaction simulation returned a success status, but the result data is missing. Review the simulated transaction parameters for further for troubleshooting.', - meta: { data: { simulation } }, - }) -} - -const transactionNeedsRestore = (simulation: SorobanRpc.Api.SimulateTransactionRestoreResponse): StellarPlusError => { +const contractEngineClassFailedToInitialize = () => { return new StellarPlusError({ - code: ContractEngineErrorCodes.CE102, - message: 'A footprint restore is required!', + code: ContractEngineErrorCodes.CE009, + message: 'Contract engine class failed to initialize!', source: 'ContractEngine', details: - 'The transaction simulation returned a restore status. This usually indicates the contract instance or the storage data has reached its limit. Review the meta data for further information about this error. It might be possible to restore the contract state by extending the contract instance or the storage data TTL.', - meta: { sorobanSimulationData: extractSimulationRestoreData(simulation), data: { simulation } }, - }) -} - -const couldntVerifyTransactionSimulation = ( - simulation: SorobanRpc.Api.SimulateTransactionResponse -): StellarPlusError => { - return new StellarPlusError({ - code: ContractEngineErrorCodes.CE100, - message: 'Unexpected error in transaction simulation!', - source: 'ContractEngine', - details: - 'Unexpected error in transaction simulation! The transaction simulation returned an unexpected status. Review the meta data for further information about this error.', - meta: { sorobanSimulationData: extractSimulationBaseData(simulation), data: { simulation } }, - }) -} - -const restoreOptionNotSet = (simulation: SorobanRpc.Api.SimulateTransactionRestoreResponse): StellarPlusError => { - return new StellarPlusError({ - code: ContractEngineErrorCodes.CE200, - message: 'Restore option not set!', - source: 'ContractEngine', - details: - 'Restore option not set! This function requires a restore option to be defined in this instance. You can either initialize the contract engine with a restore option or use the "restore" function to restore the contract state from a previous transaction.', - meta: { sorobanSimulationData: extractSimulationRestoreData(simulation), data: { simulation } }, + 'Contract engine class failed to initialize because of missing parameters! The Contract Engine must be initialized with either the wasm file, the wasm hash, or the contract ID. Please review the initialization parameters and try again.', }) } const failedToUploadWasm = (error: StellarPlusError): StellarPlusError => { return new StellarPlusError({ - code: ContractEngineErrorCodes.CE201, + code: ContractEngineErrorCodes.CE101, message: 'Failed to upload wasm!', - source: 'SorobanTransactionProcessor', + source: 'ContractEngine', details: 'The wasm file could not be uploaded. Review the meta error to identify the underlying cause for this issue.', meta: { message: error.message, error: error }, @@ -192,9 +136,9 @@ const failedToUploadWasm = (error: StellarPlusError): StellarPlusError => { const failedToDeployContract = (error: StellarPlusError): StellarPlusError => { return new StellarPlusError({ - code: ContractEngineErrorCodes.CE202, + code: ContractEngineErrorCodes.CE102, message: 'Failed to deploy contract!', - source: 'SorobanTransactionProcessor', + source: 'ContractEngine', details: 'The contract could not be deployed. Review the meta error to identify the underlying cause for this issue.', meta: { message: error.message, ...error.meta }, @@ -203,9 +147,9 @@ const failedToDeployContract = (error: StellarPlusError): StellarPlusError => { const failedToWrapAsset = (error: StellarPlusError): StellarPlusError => { return new StellarPlusError({ - code: ContractEngineErrorCodes.CE203, + code: ContractEngineErrorCodes.CE103, message: 'Failed to wrap asset!', - source: 'SorobanTransactionProcessor', + source: 'ContractEngine', details: 'The asset could not be wrapped. Review the meta error to identify the underlying cause for this issue.', meta: { message: error.message, ...error.meta }, }) @@ -215,16 +159,12 @@ export const CEError = { missingContractId, missingWasm, missingWasmHash, - couldntVerifyTransactionSimulation, - simulationFailed, contractIdAlreadySet, contractInstanceNotFound, contractInstanceMissingLiveUntilLedgerSeq, contractCodeNotFound, contractCodeMissingLiveUntilLedgerSeq, - transactionNeedsRestore, - simulationMissingResult, - restoreOptionNotSet, + contractEngineClassFailedToInitialize, failedToUploadWasm, failedToDeployContract, failedToWrapAsset, diff --git a/src/stellar-plus/core/contract-engine/index.ts b/src/stellar-plus/core/contract-engine/index.ts index f1e1083..609e515 100644 --- a/src/stellar-plus/core/contract-engine/index.ts +++ b/src/stellar-plus/core/contract-engine/index.ts @@ -57,13 +57,13 @@ export class ContractEngine { /** * * @param {NetworkConfig} networkConfig - The network to use. - * @param {ContractSpec} spec - The contract specification. - * @param {string=} contractId - The contract id. - * @param {RpcHandler=} rpcHandler - A custom RPC handler to use when interacting with the network RPC server. - * @param {Options=} options - A set of custom options to modify the behavior of the contract engine. - * @param {boolean=} options.debug - A flag to enable debug mode. This will toggle the extraction of transaction resources consumed with each transaction/simiulation. - * @param {CostHandler=} options.costHandler - A custom function to handle the transaction resources consumed with each transaction/simulation. Whn not provided, the default cost handler will be used and the resources will be logged to the console. - * @param {TransactionInvocation=} options.restoreTxInvocation - The transaction invocation object to use when automatically restoring the contract footprint. When this parameter is provided, whenever a simulation indicates that the contract footprint needs to be restored, the contract engine will automatically restore the footprint using the provided transaction invocation object. + * @param contractParameters - The contract parameters. + * @param {ContractSpec} contractParameters.spec - The contract specification object. + * @param {string=} contractParameters.contractId - The contract id. + * @param {Buffer=} contractParameters.wasm - The contract wasm file as a buffer. + * @param {string=} contractParameters.wasmHash - The contract wasm hash id. + * @param {Options=} options - A set of custom options to modify the behavior of the contract engine. + * @param {SorobanTransactionPipelineOptions=} options.sorobanTransactionPipeline - The Soroban transaction pipeline. * @description - The contract engine is used for interacting with contracts on the network. This class can be extended to create a contract client, abstracting away the Soroban integration. * * @example - The following example shows how to invoke a contract method that alters the state of the contract. @@ -104,6 +104,9 @@ export class ContractEngine { this.contractId = contractParameters.contractId this.wasm = contractParameters.wasm this.wasmHash = contractParameters.wasmHash + + if (!this.contractId && !this.wasm && !this.wasmHash) throw CEError.contractEngineClassFailedToInitialize() + this.options = { ...options } this.sorobanTransactionPipeline = new SorobanTransactionPipeline(networkConfig, { diff --git a/src/stellar-plus/core/contract-engine/index.unit.test.ts b/src/stellar-plus/core/contract-engine/index.unit.test.ts new file mode 100644 index 0000000..9c7e50c --- /dev/null +++ b/src/stellar-plus/core/contract-engine/index.unit.test.ts @@ -0,0 +1,643 @@ +import { ContractEngine } from '../contract-engine' +import { Constants } from 'stellar-plus' +import { spec as tokenSpec, methods as tokenMethods } from 'stellar-plus/asset/soroban-token/constants' +import { CEError } from './errors' +import { TransactionInvocation } from 'stellar-plus/types' +import { SorobanInvokeArgs } from './types' +import { SorobanTransactionPipeline } from 'stellar-plus/core/pipelines/soroban-transaction' +import { ContractIdOutput, ContractWasmHashOutput } from '../pipelines/soroban-get-transaction/types' +import { Asset, Contract, SorobanRpc, xdr } from '@stellar/stellar-sdk' +import { DefaultRpcHandler } from 'stellar-plus/rpc' +import { StellarPlusError } from 'stellar-plus/error' + +jest.mock('stellar-plus/core/pipelines/soroban-transaction', () => ({ + SorobanTransactionPipeline: jest.fn(), +})) + +jest.mock('stellar-plus/rpc/default-handler', () => ({ + DefaultRpcHandler: jest.fn(), +})) + +const MOCKED_SOROBAN_TRANSACTION_PIPELINE = SorobanTransactionPipeline as jest.Mock +const MOCKED_DEFAULT_RPC_HANDLER = DefaultRpcHandler as jest.Mock + +const MOCKED_CONTRACT_ID = 'CBJT4BOMRHYKHZ6HF3QG4YR7Q63BE44G73M4MALDTQ3SQVUZDE7GN35I' +const MOCKED_WASM_HASH = 'eb94566536d7f56c353b4760f6e359eca3631b70d295820fb6de55a796e019ae' +const MOCKED_CONTRACT_SPEC = tokenSpec +const MOCKED_WASM_FILE = Buffer.from('mockWasm', 'utf-8') +const MOCKED_STELLAR_ASSET = Asset.native() + +const MOCKED_CONTRACT_CODE_KEY = new xdr.LedgerKeyContractCode({ + hash: Buffer.from(MOCKED_WASM_HASH, 'hex'), +}) +const NETWORK_CONFIG = Constants.testnet +const MOCKED_TX_INVOCATION: TransactionInvocation = { + header: { + source: 'GACF23GKVFTU77K6W6PWSVN7YBM63UHDULILIEXJO6FR4YKMJ7FW3DTI', + fee: '100', + timeout: 100, + }, + signers: [], +} + +const MOCKED_SOROBAN_INVOKE_ARGS: SorobanInvokeArgs<{}> = { + method: tokenMethods.name, + methodArgs: {}, + ...MOCKED_TX_INVOCATION, +} + +describe('ContractEngine', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Intialization', () => { + it('should initialize with wasm file', () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasm: MOCKED_WASM_FILE, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + expect(contractEngine.getWasm()).toEqual(MOCKED_WASM_FILE) + }) + + it('should initialize with wasm hash', () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + expect(contractEngine.getWasmHash()).toEqual(MOCKED_WASM_HASH) + }) + + it('should initialize with contract id', () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + expect(contractEngine.getContractId()).toEqual(MOCKED_CONTRACT_ID) + }) + }) + + describe('Initialization Errors', () => { + it('should throw error if no wasm file, wasm hash or contract id is provided', () => { + expect(() => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + spec: MOCKED_CONTRACT_SPEC, + }, + }) + }).toThrow(CEError.contractEngineClassFailedToInitialize()) + }) + + it('should throw error if wasm file is required but is not present', async () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + spec: MOCKED_CONTRACT_SPEC, + wasmHash: MOCKED_WASM_HASH, + }, + }) + + expect(() => contractEngine.getWasm()).toThrow(CEError.missingWasm()) + + await expect(contractEngine.uploadWasm(MOCKED_TX_INVOCATION)).rejects.toThrow(CEError.missingWasm()) + }) + + it('should throw error if wasm hash is required but is not present', async () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + spec: MOCKED_CONTRACT_SPEC, + wasm: MOCKED_WASM_FILE, + }, + }) + + expect(() => contractEngine.getWasmHash()).toThrow(CEError.missingWasmHash()) + + await expect(contractEngine.deploy(MOCKED_TX_INVOCATION)).rejects.toThrow(CEError.missingWasmHash()) + await expect(contractEngine.getContractCodeLiveUntilLedgerSeq()).rejects.toThrow(CEError.missingWasmHash()) + await expect(contractEngine.getContractInstanceLiveUntilLedgerSeq()).rejects.toThrow(CEError.missingWasmHash()) + await expect(contractEngine.restoreContractCode(MOCKED_TX_INVOCATION)).rejects.toThrow(CEError.missingWasmHash()) + await expect(contractEngine.restoreContractInstance(MOCKED_TX_INVOCATION)).rejects.toThrow( + CEError.missingWasmHash() + ) + }) + + it('should throw error if contract id is required but is not present', async () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + spec: MOCKED_CONTRACT_SPEC, + wasmHash: MOCKED_WASM_HASH, + }, + }) + + expect(() => contractEngine.getContractId()).toThrow(CEError.missingContractId()) + expect(() => contractEngine.getContractFootprint()).toThrow(CEError.missingContractId()) + + await expect(contractEngine.invokeContract(MOCKED_SOROBAN_INVOKE_ARGS)).rejects.toThrow( + CEError.missingContractId() + ) + await expect(contractEngine.readFromContract(MOCKED_SOROBAN_INVOKE_ARGS)).rejects.toThrow( + CEError.missingContractId() + ) + await expect(contractEngine.runTransactionPipeline(MOCKED_SOROBAN_INVOKE_ARGS)).rejects.toThrow( + CEError.missingContractId() + ) + }) + }) + + describe('Initialization workflow', () => { + it('should upload wasm', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockResolvedValue({ + output: { + wasmHash: MOCKED_WASM_HASH, + } as ContractWasmHashOutput, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasm: MOCKED_WASM_FILE, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.uploadWasm(MOCKED_TX_INVOCATION)).resolves.toBeUndefined() + expect(contractEngine.getWasm()).toEqual(MOCKED_WASM_FILE) + }) + + it('should deploy contract', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockResolvedValue({ + output: { + contractId: MOCKED_CONTRACT_ID, + } as ContractIdOutput, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.deploy(MOCKED_TX_INVOCATION)).resolves.toBeUndefined() + expect(contractEngine.getContractId()).toEqual(MOCKED_CONTRACT_ID) + }) + }) + + describe('Additional getters', () => { + it('should return contract footprint', () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + const footprint = new Contract(MOCKED_CONTRACT_ID).getFootprint() + expect(contractEngine.getContractFootprint()).toEqual(footprint) + }) + + it('should return the rpc handler', () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + expect(contractEngine.getRpcHandler()).toBeDefined() + }) + + it('should throw if contract code is missing the live until ledger seq', async () => { + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [ + { + key: xdr.LedgerKey.contractCode(MOCKED_CONTRACT_CODE_KEY), + xdr: 'xdr', + }, + ], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.getContractCodeLiveUntilLedgerSeq()).rejects.toThrow( + CEError.contractCodeMissingLiveUntilLedgerSeq() + ) + }) + + it('should return the live until ledger seq for contract code', async () => { + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [ + { + key: Object.assign(xdr.LedgerKey.contractCode(MOCKED_CONTRACT_CODE_KEY)), + xdr: 'xdr', + liveUntilLedgerSeq: 1, + }, + ], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.getContractCodeLiveUntilLedgerSeq()).resolves.toEqual(1) + }) + + it('should throw if contract instance is missing the live until ledger seq', async () => { + const footprint = new Contract(MOCKED_CONTRACT_ID).getFootprint() + + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [ + { + key: footprint, + xdr: 'xdr', + }, + ], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.getContractInstanceLiveUntilLedgerSeq()).rejects.toThrow( + CEError.contractInstanceMissingLiveUntilLedgerSeq() + ) + }) + + it('should return the live until ledger seq for contract instance', async () => { + const footprint = new Contract(MOCKED_CONTRACT_ID).getFootprint() + + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [ + { + key: footprint, + xdr: 'xdr', + liveUntilLedgerSeq: 1, + }, + ], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.getContractInstanceLiveUntilLedgerSeq()).resolves.toEqual(1) + }) + }) + + describe('Contract restore workflows', () => { + it('should fail to restore a contract code when contract code is not found', async () => { + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.restoreContractCode(MOCKED_TX_INVOCATION)).rejects.toThrow( + CEError.contractCodeNotFound({ + entries: [], + latestLedger: 1, + } as SorobanRpc.Api.GetLedgerEntriesResponse) + ) + }) + + it('should restore a contract code', async () => { + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [ + { + key: xdr.LedgerKey.contractCode(MOCKED_CONTRACT_CODE_KEY), + xdr: 'xdr', + }, + ], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.restoreContractCode(MOCKED_TX_INVOCATION)).resolves.toBeUndefined() + }) + + it('should fail to restore a contract instance when contract instance is not found', async () => { + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.restoreContractInstance(MOCKED_TX_INVOCATION)).rejects.toThrow( + CEError.contractInstanceNotFound({ + entries: [], + latestLedger: 1, + } as SorobanRpc.Api.GetLedgerEntriesResponse) + ) + }) + + it('should restore a contract instance', async () => { + const footprint = new Contract(MOCKED_CONTRACT_ID).getFootprint() + + MOCKED_DEFAULT_RPC_HANDLER.mockImplementation(() => { + return { + getLedgerEntries: jest.fn().mockResolvedValue({ + entries: [ + { + key: footprint, + xdr: 'xdr', + }, + ], + latestLedger: 1, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.restoreContractInstance(MOCKED_TX_INVOCATION)).resolves.toBeUndefined() + }) + }) + + describe('Contract invocation', () => { + it('should not wrap and deploy with a contract id', async () => { + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect( + contractEngine.wrapAndDeployClassicAsset({ asset: MOCKED_STELLAR_ASSET, ...MOCKED_TX_INVOCATION }) + ).rejects.toThrow(CEError.contractIdAlreadySet()) + }) + + it('should wrap and deploy a classic asset', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockResolvedValue({ + output: { + contractId: MOCKED_CONTRACT_ID, + } as ContractIdOutput, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect( + contractEngine.wrapAndDeployClassicAsset({ asset: MOCKED_STELLAR_ASSET, ...MOCKED_TX_INVOCATION }) + ).resolves.toBeUndefined() + + expect(contractEngine.getContractId()).toEqual(MOCKED_CONTRACT_ID) + }) + + it('should surface exceptions from the transaction pipeline when wrapping and deploying a classic asset', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockRejectedValue(StellarPlusError.unexpectedError()), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect( + contractEngine.wrapAndDeployClassicAsset({ asset: MOCKED_STELLAR_ASSET, ...MOCKED_TX_INVOCATION }) + ).rejects.toThrow(CEError.failedToWrapAsset(StellarPlusError.unexpectedError())) + }) + + it('should surface exceptions from the transaction pipeline when deploying a contract', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockRejectedValue(StellarPlusError.unexpectedError()), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasm: MOCKED_WASM_FILE, + wasmHash: MOCKED_WASM_HASH, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.deploy(MOCKED_TX_INVOCATION)).rejects.toThrow( + CEError.failedToDeployContract(StellarPlusError.unexpectedError()) + ) + }) + + it('should surface exceptions from the transaction pipeline when uploading a wasm', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockRejectedValue(StellarPlusError.unexpectedError()), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + wasm: MOCKED_WASM_FILE, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.uploadWasm(MOCKED_TX_INVOCATION)).rejects.toThrow( + CEError.failedToUploadWasm(StellarPlusError.unexpectedError()) + ) + }) + + it('should surface exceptions from the transaction pipeline when invoking a contract', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockRejectedValue(StellarPlusError.unexpectedError()), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.invokeContract(MOCKED_SOROBAN_INVOKE_ARGS)).rejects.toThrow( + StellarPlusError.unexpectedError() + ) + }) + + it('should surface exceptions from the transaction pipeline when reading from a contract', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockRejectedValue(StellarPlusError.unexpectedError()), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.readFromContract(MOCKED_SOROBAN_INVOKE_ARGS)).rejects.toThrow( + StellarPlusError.unexpectedError() + ) + }) + + it('should invoke a contract', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockResolvedValue({ + output: { value: true }, + }), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.invokeContract(MOCKED_SOROBAN_INVOKE_ARGS)).resolves.toEqual(true) + }) + + it('should read from a contract', async () => { + MOCKED_SOROBAN_TRANSACTION_PIPELINE.mockImplementation(() => { + return { + execute: jest.fn().mockResolvedValue(true), + } + }) + + const contractEngine = new ContractEngine({ + networkConfig: NETWORK_CONFIG, + contractParameters: { + contractId: MOCKED_CONTRACT_ID, + spec: MOCKED_CONTRACT_SPEC, + }, + }) + + await expect(contractEngine.readFromContract(MOCKED_SOROBAN_INVOKE_ARGS)).resolves.toEqual(true) + }) + }) +}) diff --git a/src/stellar-plus/core/pipelines/submit-transaction/index.ts b/src/stellar-plus/core/pipelines/submit-transaction/index.ts index c528b23..7f20f6d 100644 --- a/src/stellar-plus/core/pipelines/submit-transaction/index.ts +++ b/src/stellar-plus/core/pipelines/submit-transaction/index.ts @@ -1,7 +1,6 @@ import { FeeBumpTransaction, SorobanRpc, Transaction } from '@stellar/stellar-sdk' import { HorizonApi } from '@stellar/stellar-sdk/lib/horizon' -import { HorizonHandler } from 'stellar-plus' import { SubmitTransactionPipelineInput, SubmitTransactionPipelineOutput, @@ -13,6 +12,7 @@ import { RpcHandler } from 'stellar-plus/rpc/types' import { ConveyorBelt } from 'stellar-plus/utils/pipeline/conveyor-belts' import { PSUError } from './errors' +import { HorizonHandlerClient } from 'stellar-plus/horizon' export class SubmitTransactionPipeline extends ConveyorBelt< SubmitTransactionPipelineInput, @@ -37,7 +37,7 @@ export class SubmitTransactionPipeline extends ConveyorBelt< // Horizon Submission // =================== // - if (networkHandler instanceof HorizonHandler) { + if (networkHandler instanceof HorizonHandlerClient) { let response: HorizonApi.SubmitTransactionResponse try { response = await this.submitTransactionThroughHorizon(transaction, networkHandler) @@ -86,7 +86,7 @@ export class SubmitTransactionPipeline extends ConveyorBelt< private async submitTransactionThroughHorizon( transaction: Transaction | FeeBumpTransaction, - horizonHandler: HorizonHandler + horizonHandler: HorizonHandlerClient ): Promise { const response = (await horizonHandler.server.submitTransaction(transaction, { skipMemoRequiredCheck: true, // Not skipping memo required check causes an error when submitting fee bump transactions diff --git a/src/stellar-plus/utils/pipeline/plugins/simulate-transaction/extract-auth-entries-output/index.ts b/src/stellar-plus/utils/pipeline/plugins/simulate-transaction/extract-auth-entries-output/index.ts index a53fc47..9c89f88 100644 --- a/src/stellar-plus/utils/pipeline/plugins/simulate-transaction/extract-auth-entries-output/index.ts +++ b/src/stellar-plus/utils/pipeline/plugins/simulate-transaction/extract-auth-entries-output/index.ts @@ -24,6 +24,8 @@ export class ExtractAuthEntriesFromSimulationPlugin const { response, output } = item if (!response.result) { + // TODO: + // implement error handling here and migrate older CE Error // throw CEError.simulationMissingResult(simulated) throw new Error('simulationMissingResult') }