diff --git a/src-network-providers/.npmignore b/src-network-providers/.npmignore new file mode 100644 index 00000000..4c56e2af --- /dev/null +++ b/src-network-providers/.npmignore @@ -0,0 +1 @@ +src/testscommon/** diff --git a/src-network-providers/accounts.ts b/src-network-providers/accounts.ts new file mode 100644 index 00000000..7c2a29dc --- /dev/null +++ b/src-network-providers/accounts.ts @@ -0,0 +1,80 @@ +import BigNumber from "bignumber.js"; +import { IAddress } from "./interface"; +import { Address } from "./primitives"; + +/** + * A plain view of an account, as queried from the Network. + */ +export class AccountOnNetwork { + address: IAddress = new Address(""); + nonce: number = 0; + balance: BigNumber = new BigNumber(0); + code: string = ""; + userName: string = ""; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromHttpResponse(payload: any): AccountOnNetwork { + let result = new AccountOnNetwork(); + + result.address = new Address(payload["address"] || ""); + result.nonce = Number(payload["nonce"] || 0); + result.balance = new BigNumber(payload["balance"] || 0); + result.code = payload["code"] || ""; + result.userName = payload["username"] || ""; + + return result; + } +} + +export class GuardianData { + guarded: boolean = false; + activeGuardian?: Guardian; + pendingGuardian?: Guardian; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromHttpResponse(response: any): GuardianData { + const result = new GuardianData(); + + result.guarded = response["guarded"] || false; + + if (response["activeGuardian"]) { + result.activeGuardian = Guardian.fromHttpResponse(response["activeGuardian"]); + } + + if (response["pendingGuardian"]) { + result.pendingGuardian = Guardian.fromHttpResponse(response["pendingGuardian"]); + } + + return result; + } + + getCurrentGuardianAddress(): IAddress | undefined { + if (!this.guarded) { + return undefined; + } + + return this.activeGuardian?.address; + } +} + +class Guardian { + activationEpoch: number = 0; + address: IAddress = new Address(""); + serviceUID: string = ""; + + static fromHttpResponse(responsePart: any): Guardian { + const result = new Guardian(); + + result.activationEpoch = Number(responsePart["activationEpoch"] || 0); + result.address = new Address(responsePart["address"] || ""); + result.serviceUID = responsePart["serviceUID"] || ""; + + return result; + } +} diff --git a/src-network-providers/apiNetworkProvider.ts b/src-network-providers/apiNetworkProvider.ts new file mode 100644 index 00000000..3d6bc556 --- /dev/null +++ b/src-network-providers/apiNetworkProvider.ts @@ -0,0 +1,229 @@ +import axios from "axios"; +import { AccountOnNetwork, GuardianData } from "./accounts"; +import { defaultAxiosConfig, defaultPagination } from "./config"; +import { BaseUserAgent } from "./constants"; +import { ContractQueryRequest } from "./contractQueryRequest"; +import { ContractQueryResponse } from "./contractQueryResponse"; +import { ErrContractQuery, ErrNetworkProvider } from "./errors"; +import { IAddress, IContractQuery, INetworkProvider, IPagination, ITransaction, ITransactionNext } from "./interface"; +import { NetworkConfig } from "./networkConfig"; +import { NetworkGeneralStatistics } from "./networkGeneralStatistics"; +import { NetworkProviderConfig } from "./networkProviderConfig"; +import { NetworkStake } from "./networkStake"; +import { NetworkStatus } from "./networkStatus"; +import { PairOnNetwork } from "./pairs"; +import { Nonce } from "./primitives"; +import { ProxyNetworkProvider } from "./proxyNetworkProvider"; +import { DefinitionOfFungibleTokenOnNetwork, DefinitionOfTokenCollectionOnNetwork } from "./tokenDefinitions"; +import { FungibleTokenOfAccountOnNetwork, NonFungibleTokenOfAccountOnNetwork } from "./tokens"; +import { TransactionOnNetwork, prepareTransactionForBroadcasting } from "./transactions"; +import { TransactionStatus } from "./transactionStatus"; +import { extendUserAgent } from "./userAgent"; + +// TODO: Find & remove duplicate code between "ProxyNetworkProvider" and "ApiNetworkProvider". +export class ApiNetworkProvider implements INetworkProvider { + private url: string; + private config: NetworkProviderConfig; + private backingProxyNetworkProvider; + private userAgentPrefix = `${BaseUserAgent}/api` + + constructor(url: string, config?: NetworkProviderConfig) { + this.url = url; + let proxyConfig = this.getProxyConfig(config); + this.config = { ...defaultAxiosConfig, ...config }; + this.backingProxyNetworkProvider = new ProxyNetworkProvider(url, proxyConfig); + extendUserAgent(this.userAgentPrefix, this.config); + } + + private getProxyConfig(config: NetworkProviderConfig | undefined) { + let proxyConfig = JSON.parse(JSON.stringify(config || {})); + proxyConfig = { ...defaultAxiosConfig, ...proxyConfig }; + return proxyConfig; + } + + async getNetworkConfig(): Promise { + return await this.backingProxyNetworkProvider.getNetworkConfig(); + } + + async getNetworkStatus(): Promise { + return await this.backingProxyNetworkProvider.getNetworkStatus(); + } + + async getNetworkStakeStatistics(): Promise { + let response = await this.doGetGeneric("stake"); + let networkStake = NetworkStake.fromHttpResponse(response); + return networkStake; + } + + async getNetworkGeneralStatistics(): Promise { + let response = await this.doGetGeneric("stats"); + let stats = NetworkGeneralStatistics.fromHttpResponse(response); + return stats; + } + + async getAccount(address: IAddress): Promise { + let response = await this.doGetGeneric(`accounts/${address.bech32()}`); + let account = AccountOnNetwork.fromHttpResponse(response); + return account; + } + + async getGuardianData(address: IAddress): Promise { + return await this.backingProxyNetworkProvider.getGuardianData(address); + } + + async getFungibleTokensOfAccount(address: IAddress, pagination?: IPagination): Promise { + pagination = pagination || defaultPagination; + + let url = `accounts/${address.bech32()}/tokens?${this.buildPaginationParams(pagination)}`; + let response: any[] = await this.doGetGeneric(url); + let tokens = response.map(item => FungibleTokenOfAccountOnNetwork.fromHttpResponse(item)); + + // TODO: Fix sorting + tokens.sort((a, b) => a.identifier.localeCompare(b.identifier)); + return tokens; + } + + async getNonFungibleTokensOfAccount(address: IAddress, pagination?: IPagination): Promise { + pagination = pagination || defaultPagination; + + let url = `accounts/${address.bech32()}/nfts?${this.buildPaginationParams(pagination)}`; + let response: any[] = await this.doGetGeneric(url); + let tokens = response.map(item => NonFungibleTokenOfAccountOnNetwork.fromApiHttpResponse(item)); + + // TODO: Fix sorting + tokens.sort((a, b) => a.identifier.localeCompare(b.identifier)); + return tokens; + } + + async getFungibleTokenOfAccount(address: IAddress, tokenIdentifier: string): Promise { + let response = await this.doGetGeneric(`accounts/${address.bech32()}/tokens/${tokenIdentifier}`); + let tokenData = FungibleTokenOfAccountOnNetwork.fromHttpResponse(response); + return tokenData; + } + + async getNonFungibleTokenOfAccount(address: IAddress, collection: string, nonce: number): Promise { + let nonceAsHex = new Nonce(nonce).hex(); + let response = await this.doGetGeneric(`accounts/${address.bech32()}/nfts/${collection}-${nonceAsHex}`); + let tokenData = NonFungibleTokenOfAccountOnNetwork.fromApiHttpResponse(response); + return tokenData; + } + + async getMexPairs(pagination?: IPagination): Promise { + let url = `mex/pairs`; + if (pagination) { + url = `${url}?from=${pagination.from}&size=${pagination.size}`; + } + + let response: any[] = await this.doGetGeneric(url); + + return response.map(item => PairOnNetwork.fromApiHttpResponse(item)); + } + + async getTransaction(txHash: string): Promise { + let response = await this.doGetGeneric(`transactions/${txHash}`); + let transaction = TransactionOnNetwork.fromApiHttpResponse(txHash, response); + return transaction; + } + + async getTransactionStatus(txHash: string): Promise { + let response = await this.doGetGeneric(`transactions/${txHash}?fields=status`); + let status = new TransactionStatus(response.status); + return status; + } + + async sendTransaction(tx: ITransaction | ITransactionNext): Promise { + const transaction = prepareTransactionForBroadcasting(tx); + const response = await this.doPostGeneric("transactions", transaction); + return response.txHash; + } + + async sendTransactions(txs: (ITransaction | ITransactionNext)[]): Promise { + return await this.backingProxyNetworkProvider.sendTransactions(txs); + } + + async simulateTransaction(tx: ITransaction | ITransactionNext): Promise { + return await this.backingProxyNetworkProvider.simulateTransaction(tx); + } + + async queryContract(query: IContractQuery): Promise { + try { + let request = new ContractQueryRequest(query).toHttpRequest(); + let response = await this.doPostGeneric("query", request); + return ContractQueryResponse.fromHttpResponse(response); + } catch (error: any) { + throw new ErrContractQuery(error); + } + } + + async getDefinitionOfFungibleToken(tokenIdentifier: string): Promise { + let response = await this.doGetGeneric(`tokens/${tokenIdentifier}`); + let definition = DefinitionOfFungibleTokenOnNetwork.fromApiHttpResponse(response); + return definition; + } + + async getDefinitionOfTokenCollection(collection: string): Promise { + let response = await this.doGetGeneric(`collections/${collection}`); + let definition = DefinitionOfTokenCollectionOnNetwork.fromApiHttpResponse(response); + return definition; + } + + async getNonFungibleToken(collection: string, nonce: number): Promise { + let nonceAsHex = new Nonce(nonce).hex(); + let response = await this.doGetGeneric(`nfts/${collection}-${nonceAsHex}`); + let token = NonFungibleTokenOfAccountOnNetwork.fromApiHttpResponse(response); + return token; + } + + async doGetGeneric(resourceUrl: string): Promise { + let response = await this.doGet(resourceUrl); + return response; + } + + async doPostGeneric(resourceUrl: string, payload: any): Promise { + let response = await this.doPost(resourceUrl, payload); + return response; + } + + private buildPaginationParams(pagination: IPagination) { + return `from=${pagination.from}&size=${pagination.size}`; + } + + private async doGet(resourceUrl: string): Promise { + let url = `${this.url}/${resourceUrl}`; + + try { + let response = await axios.get(url, this.config); + return response.data; + } catch (error) { + this.handleApiError(error, resourceUrl); + } + } + + private async doPost(resourceUrl: string, payload: any): Promise { + let url = `${this.url}/${resourceUrl}`; + + try { + let response = await axios.post(url, payload, { + ...this.config, + headers: { + "Content-Type": "application/json", + ...this.config.headers, + }, + }); + let responsePayload = response.data; + return responsePayload; + } catch (error) { + this.handleApiError(error, resourceUrl); + } + } + + private handleApiError(error: any, resourceUrl: string) { + if (!error.response) { + throw new ErrNetworkProvider(resourceUrl, error.toString(), error); + } + + const errorData = error.response.data; + const originalErrorMessage = errorData.message || errorData.error || JSON.stringify(errorData); + throw new ErrNetworkProvider(resourceUrl, originalErrorMessage, error); + } +} diff --git a/src-network-providers/config.ts b/src-network-providers/config.ts new file mode 100644 index 00000000..8239bfda --- /dev/null +++ b/src-network-providers/config.ts @@ -0,0 +1,18 @@ +import { IPagination } from "./interface"; + +const JSONbig = require("json-bigint")({ constructorAction: 'ignore' }); + +export const defaultAxiosConfig = { + timeout: 5000, + // See: https://github.com/axios/axios/issues/983 regarding transformResponse + transformResponse: [ + function (data: any) { + return JSONbig.parse(data); + } + ] +}; + +export const defaultPagination: IPagination = { + from: 0, + size: 100 +}; diff --git a/src-network-providers/constants.ts b/src-network-providers/constants.ts new file mode 100644 index 00000000..ea134f20 --- /dev/null +++ b/src-network-providers/constants.ts @@ -0,0 +1,7 @@ +import BigNumber from "bignumber.js"; +import { Address } from "./primitives"; + +export const MaxUint64AsBigNumber = new BigNumber("18446744073709551615"); +export const EsdtContractAddress = new Address("erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzllls8a5w6u"); +export const BaseUserAgent = "multiversx-sdk" +export const UnknownClientName = "unknown" diff --git a/src-network-providers/contractQueryRequest.ts b/src-network-providers/contractQueryRequest.ts new file mode 100644 index 00000000..d64183c9 --- /dev/null +++ b/src-network-providers/contractQueryRequest.ts @@ -0,0 +1,21 @@ +import { IContractQuery } from "./interface"; + +export class ContractQueryRequest { + private readonly query: IContractQuery; + + constructor(query: IContractQuery) { + this.query = query; + } + + toHttpRequest() { + let request: any = {}; + let query = this.query; + request.scAddress = query.address.bech32(); + request.caller = query.caller?.bech32() ? query.caller.bech32() : undefined; + request.funcName = query.func.toString(); + request.value = query.value ? query.value.toString() : undefined; + request.args = query.getEncodedArguments(); + + return request; + } +} diff --git a/src-network-providers/contractQueryResponse.ts b/src-network-providers/contractQueryResponse.ts new file mode 100644 index 00000000..40da0912 --- /dev/null +++ b/src-network-providers/contractQueryResponse.ts @@ -0,0 +1,50 @@ +import BigNumber from "bignumber.js"; +import { MaxUint64AsBigNumber } from "./constants"; + +export class ContractQueryResponse { + returnData: string[]; + returnCode: string; + returnMessage: string; + gasUsed: number; + + constructor(init?: Partial) { + this.returnData = init?.returnData || []; + this.returnCode = init?.returnCode || ""; + this.returnMessage = init?.returnMessage || ""; + this.gasUsed = init?.gasUsed || 0; + } + + /** + * Constructs a QueryResponse object from a HTTP response (as returned by the provider). + */ + static fromHttpResponse(payload: any): ContractQueryResponse { + let returnData = payload["returnData"] || payload["ReturnData"]; + let returnCode = payload["returnCode"] || payload["ReturnCode"]; + let returnMessage = payload["returnMessage"] || payload["ReturnMessage"]; + let gasRemaining = new BigNumber(payload["gasRemaining"] || payload["GasRemaining"] || 0); + let gasUsed = MaxUint64AsBigNumber.minus(gasRemaining).toNumber(); + + return new ContractQueryResponse({ + returnData: returnData, + returnCode: returnCode, + returnMessage: returnMessage, + gasUsed: gasUsed, + }); + } + + getReturnDataParts(): Buffer[] { + return this.returnData.map((item) => Buffer.from(item || "", "base64")); + } + + /** + * Converts the object to a pretty, plain JavaScript object. + */ + toJSON(): object { + return { + returnData: this.returnData, + returnCode: this.returnCode, + returnMessage: this.returnMessage, + gasUsed: this.gasUsed.valueOf(), + }; + } +} diff --git a/src-network-providers/contractResults.ts b/src-network-providers/contractResults.ts new file mode 100644 index 00000000..37046fb0 --- /dev/null +++ b/src-network-providers/contractResults.ts @@ -0,0 +1,80 @@ +import { IAddress } from "./interface"; +import { TransactionLogs } from "./transactionLogs"; +import { Address } from "./primitives"; + +export class ContractResults { + readonly items: ContractResultItem[]; + + constructor(items: ContractResultItem[]) { + this.items = items; + + this.items.sort(function (a: ContractResultItem, b: ContractResultItem) { + return a.nonce.valueOf() - b.nonce.valueOf(); + }); + } + + static fromProxyHttpResponse(results: any[]): ContractResults { + let items = results.map(item => ContractResultItem.fromProxyHttpResponse(item)); + return new ContractResults(items); + } + + static fromApiHttpResponse(results: any[]): ContractResults { + let items = results.map(item => ContractResultItem.fromApiHttpResponse(item)); + return new ContractResults(items); + } +} + +export class ContractResultItem { + hash: string = ""; + nonce: number = 0; + value: string = ""; + receiver: IAddress = new Address(""); + sender: IAddress = new Address(""); + data: string = ""; + previousHash: string = ""; + originalHash: string = ""; + gasLimit: number = 0; + gasPrice: number = 0; + callType: number = 0; + returnMessage: string = ""; + logs: TransactionLogs = new TransactionLogs(); + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromProxyHttpResponse(response: any): ContractResultItem { + let item = ContractResultItem.fromHttpResponse(response); + return item; + } + + static fromApiHttpResponse(response: any): ContractResultItem { + let item = ContractResultItem.fromHttpResponse(response); + + item.data = Buffer.from(item.data, "base64").toString(); + item.callType = Number(item.callType); + + return item; + } + + private static fromHttpResponse(response: any): ContractResultItem { + let item = new ContractResultItem(); + + item.hash = response.hash; + item.nonce = Number(response.nonce || 0); + item.value = (response.value || 0).toString(); + item.receiver = new Address(response.receiver); + item.sender = new Address(response.sender); + item.previousHash = response.prevTxHash; + item.originalHash = response.originalTxHash; + item.gasLimit = Number(response.gasLimit || 0); + item.gasPrice = Number(response.gasPrice || 0); + item.data = response.data || ""; + item.callType = response.callType; + item.returnMessage = response.returnMessage; + + item.logs = TransactionLogs.fromHttpResponse(response.logs || {}); + + return item; + } +} diff --git a/src-network-providers/errors.ts b/src-network-providers/errors.ts new file mode 100644 index 00000000..6b297bfd --- /dev/null +++ b/src-network-providers/errors.ts @@ -0,0 +1,39 @@ +/** + * The base class for exceptions (errors). + */ +export class Err extends Error { + inner: Error | undefined = undefined; + + public constructor(message: string, inner?: Error) { + super(message); + this.inner = inner; + } +} + +/** + * Signals an unexpected condition. + */ +export class ErrUnexpectedCondition extends Err { + public constructor(message: string) { + super(`Unexpected condition: [${message}]`); + } +} + +/** + * Signals an error that happened during a request against the Network. + */ +export class ErrNetworkProvider extends Err { + public constructor(url: string, error: string, inner?: Error) { + let message = `Request error on url [${url}]: [${error}]`; + super(message, inner); + } +} + +/** + * Signals a generic error in the context of querying Smart Contracts. + */ +export class ErrContractQuery extends Err { + public constructor(originalError: Error) { + super(originalError.message.replace("executeQuery:", "")); + } +} diff --git a/src-network-providers/index.ts b/src-network-providers/index.ts new file mode 100644 index 00000000..804b15b6 --- /dev/null +++ b/src-network-providers/index.ts @@ -0,0 +1,20 @@ +export { ApiNetworkProvider } from "./apiNetworkProvider"; +export { ProxyNetworkProvider } from "./proxyNetworkProvider"; + +export { AccountOnNetwork } from "./accounts"; +export { ContractQueryResponse } from "./contractQueryResponse"; +export { ContractResultItem, ContractResults } from "./contractResults"; +export { TransactionEvent, TransactionEventData, TransactionEventTopic } from "./transactionEvents"; +export { TransactionLogs } from "./transactionLogs"; +export { TransactionReceipt } from "./transactionReceipt"; +export { TransactionStatus } from "./transactionStatus"; +export { TransactionOnNetwork } from "./transactions"; + +export { DefinitionOfFungibleTokenOnNetwork, DefinitionOfTokenCollectionOnNetwork } from "./tokenDefinitions"; +export { FungibleTokenOfAccountOnNetwork, NonFungibleTokenOfAccountOnNetwork } from "./tokens"; + +export { NetworkConfig } from "./networkConfig"; +export { NetworkGeneralStatistics } from "./networkGeneralStatistics"; +export { NetworkStake } from "./networkStake"; +export { NetworkStatus } from "./networkStatus"; + diff --git a/src-network-providers/interface.ts b/src-network-providers/interface.ts new file mode 100644 index 00000000..144f0583 --- /dev/null +++ b/src-network-providers/interface.ts @@ -0,0 +1,153 @@ +import { AccountOnNetwork } from "./accounts"; +import { ContractQueryResponse } from "./contractQueryResponse"; +import { NetworkConfig } from "./networkConfig"; +import { NetworkGeneralStatistics } from "./networkGeneralStatistics"; +import { NetworkStake } from "./networkStake"; +import { NetworkStatus } from "./networkStatus"; +import { DefinitionOfFungibleTokenOnNetwork, DefinitionOfTokenCollectionOnNetwork } from "./tokenDefinitions"; +import { FungibleTokenOfAccountOnNetwork, NonFungibleTokenOfAccountOnNetwork } from "./tokens"; +import { TransactionOnNetwork } from "./transactions"; +import { TransactionStatus } from "./transactionStatus"; + +/** + * An interface that defines the endpoints of an HTTP API Provider. + */ +export interface INetworkProvider { + /** + * Fetches the Network configuration. + */ + getNetworkConfig(): Promise; + + /** + * Fetches the Network status. + */ + getNetworkStatus(): Promise; + + /** + * Fetches stake statistics. + */ + getNetworkStakeStatistics(): Promise; + + /** + * Fetches general statistics. + */ + getNetworkGeneralStatistics(): Promise; + + /** + * Fetches the state of an account. + */ + getAccount(address: IAddress): Promise; + + /** + * Fetches data about the fungible tokens held by an account. + */ + getFungibleTokensOfAccount(address: IAddress, pagination?: IPagination): Promise; + + /** + * Fetches data about the non-fungible tokens held by account. + */ + getNonFungibleTokensOfAccount(address: IAddress, pagination?: IPagination): Promise; + + /** + * Fetches data about a specific fungible token held by an account. + */ + getFungibleTokenOfAccount(address: IAddress, tokenIdentifier: string): Promise; + + /** + * Fetches data about a specific non-fungible token (instance) held by an account. + */ + getNonFungibleTokenOfAccount(address: IAddress, collection: string, nonce: number): Promise; + + /** + * Fetches the state of a transaction. + */ + getTransaction(txHash: string, withProcessStatus?: boolean): Promise; + + /** + * Queries the status of a transaction. + */ + getTransactionStatus(txHash: string): Promise; + + /** + * Broadcasts an already-signed transaction. + */ + sendTransaction(tx: ITransaction | ITransactionNext): Promise; + + /** + * Broadcasts a list of already-signed transactions. + */ + sendTransactions(txs: (ITransaction | ITransactionNext)[]): Promise; + + /** + * Simulates the processing of an already-signed transaction. + * + */ + simulateTransaction(tx: ITransaction): Promise; + + /** + * Queries a Smart Contract - runs a pure function defined by the contract and returns its results. + */ + queryContract(query: IContractQuery): Promise; + + /** + * Fetches the definition of a fungible token. + */ + getDefinitionOfFungibleToken(tokenIdentifier: string): Promise; + + /** + * Fetches the definition of a SFT (including Meta ESDT) or NFT. + */ + getDefinitionOfTokenCollection(collection: string): Promise; + + /** + * Fetches data about a specific non-fungible token (instance). + */ + getNonFungibleToken(collection: string, nonce: number): Promise; + + /** + * Performs a generic GET action against the provider (useful for new HTTP endpoints). + */ + doGetGeneric(resourceUrl: string): Promise; + + /** + * Performs a generic POST action against the provider (useful for new HTTP endpoints). + */ + doPostGeneric(resourceUrl: string, payload: any): Promise; +} + +export interface IContractQuery { + address: IAddress; + caller?: IAddress; + func: { toString(): string; }; + value?: { toString(): string; }; + getEncodedArguments(): string[]; +} + +export interface IPagination { + from: number; + size: number; +} + +export interface ITransaction { + toSendable(): any; +} + +export interface IAddress { bech32(): string; } + +export interface ITransactionNext { + sender: string; + receiver: string; + gasLimit: bigint; + chainID: string; + nonce: bigint; + value: bigint; + senderUsername: string; + receiverUsername: string; + gasPrice: bigint; + data: Uint8Array; + version: number; + options: number; + guardian: string; + signature: Uint8Array; + guardianSignature: Uint8Array; + } diff --git a/src-network-providers/networkConfig.ts b/src-network-providers/networkConfig.ts new file mode 100644 index 00000000..be931e3c --- /dev/null +++ b/src-network-providers/networkConfig.ts @@ -0,0 +1,84 @@ +import BigNumber from "bignumber.js"; + +/** + * An object holding Network configuration parameters. + */ +export class NetworkConfig { + /** + * The chain ID. E.g. "1" for the Mainnet. + */ + public ChainID: string; + + /** + * The gas required by the Network to process a byte of the transaction data. + */ + public GasPerDataByte: number; + /** + * The round duration. + */ + public RoundDuration: number; + /** + * The number of rounds per epoch. + */ + public RoundsPerEpoch: number; + + /** + * The Top Up Factor for APR calculation + */ + public TopUpFactor: number; + + /** + * The Top Up Factor for APR calculation + */ + public TopUpRewardsGradientPoint: BigNumber; + + public GasPriceModifier: number; + + /** + * The minimum gas limit required to be set when broadcasting a transaction. + */ + public MinGasLimit: number; + + /** + * The minimum gas price required to be set when broadcasting a transaction. + */ + public MinGasPrice: number; + + /** + * The oldest transaction version accepted by the Network. + */ + public MinTransactionVersion: number; + + constructor() { + this.ChainID = "T"; + this.GasPerDataByte = 1500; + this.TopUpFactor = 0; + this.RoundDuration = 0; + this.RoundsPerEpoch = 0; + this.TopUpRewardsGradientPoint = new BigNumber(0); + this.MinGasLimit = 50000; + this.MinGasPrice = 1000000000; + this.GasPriceModifier = 1; + this.MinTransactionVersion = 1; + } + + /** + * Constructs a configuration object from a HTTP response (as returned by the provider). + */ + static fromHttpResponse(payload: any): NetworkConfig { + let networkConfig = new NetworkConfig(); + + networkConfig.ChainID = String(payload["erd_chain_id"]); + networkConfig.GasPerDataByte = Number(payload["erd_gas_per_data_byte"]); + networkConfig.TopUpFactor = Number(payload["erd_top_up_factor"]); + networkConfig.RoundDuration = Number(payload["erd_round_duration"]); + networkConfig.RoundsPerEpoch = Number(payload["erd_rounds_per_epoch"]); + networkConfig.TopUpRewardsGradientPoint = new BigNumber(payload["erd_rewards_top_up_gradient_point"]); + networkConfig.MinGasLimit = Number(payload["erd_min_gas_limit"]); + networkConfig.MinGasPrice = Number(payload["erd_min_gas_price"]); + networkConfig.MinTransactionVersion = Number(payload["erd_min_transaction_version"]); + networkConfig.GasPriceModifier = Number(payload["erd_gas_price_modifier"]); + + return networkConfig; + } +} diff --git a/src-network-providers/networkGeneralStatistics.ts b/src-network-providers/networkGeneralStatistics.ts new file mode 100644 index 00000000..2935b1cb --- /dev/null +++ b/src-network-providers/networkGeneralStatistics.ts @@ -0,0 +1,73 @@ +/** + * An object holding general Network statistics and parameters. + */ +export class NetworkGeneralStatistics { + /** + * The number of Shards. + */ + public Shards: number; + + /** + * The Number of Blocks. + */ + public Blocks: number; + + /** + * The Number of Accounts. + */ + public Accounts: number; + + /** + * The Number of transactions. + */ + public Transactions: number; + + /** + * The Refresh rate. + */ + public RefreshRate: number; + + /** + * The Number of the current Epoch. + */ + public Epoch: number; + + /** + * The Number of rounds passed. + */ + public RoundsPassed: number; + + /** + * The Number of Rounds per epoch. + */ + public RoundsPerEpoch: number; + + constructor() { + this.Shards = 0; + this.Blocks = 0; + this.Accounts = 0; + this.Transactions = 0; + this.RefreshRate = 0; + this.Epoch = 0; + this.RoundsPassed = 0; + this.RoundsPerEpoch = 0; + } + + /** + * Constructs a stats object from a HTTP response (as returned by the provider). + */ + static fromHttpResponse(payload: any): NetworkGeneralStatistics { + let stats = new NetworkGeneralStatistics(); + + stats.Shards = Number(payload["shards"]); + stats.Blocks = Number(payload["blocks"]); + stats.Accounts = Number(payload["accounts"]); + stats.Transactions = Number(payload["transactions"]); + stats.RefreshRate = Number(payload["refreshRate"]); + stats.Epoch = Number(payload["epoch"]); + stats.RoundsPassed = Number(payload["roundsPassed"]); + stats.RoundsPerEpoch = Number(payload["roundsPerEpoch"]); + + return stats; + } +} diff --git a/src-network-providers/networkProviderConfig.ts b/src-network-providers/networkProviderConfig.ts new file mode 100644 index 00000000..b66bffbc --- /dev/null +++ b/src-network-providers/networkProviderConfig.ts @@ -0,0 +1,5 @@ +import { AxiosRequestConfig } from 'axios'; + +export interface NetworkProviderConfig extends AxiosRequestConfig { + clientName?: string; +} diff --git a/src-network-providers/networkStake.ts b/src-network-providers/networkStake.ts new file mode 100644 index 00000000..5806f9eb --- /dev/null +++ b/src-network-providers/networkStake.ts @@ -0,0 +1,47 @@ +import BigNumber from "bignumber.js"; + +/** + * An object holding Network stake parameters. + */ +export class NetworkStake { + private static default: NetworkStake; + + /** + * The Total Validators Number. + */ + public TotalValidators: number; + + /** + * The Active Validators Number. + */ + public ActiveValidators: number; + /** + * The Queue Size. + */ + public QueueSize: number; + /** + * The Total Validators Number. + */ + public TotalStaked: BigNumber; + + constructor() { + this.TotalValidators = 0; + this.ActiveValidators = 0; + this.QueueSize = 0; + this.TotalStaked = new BigNumber(0); + } + + /** + * Constructs a configuration object from a HTTP response (as returned by the provider). + */ + static fromHttpResponse(payload: any): NetworkStake { + let networkStake = new NetworkStake(); + + networkStake.TotalValidators = Number(payload["totalValidators"]); + networkStake.ActiveValidators = Number(payload["activeValidators"]); + networkStake.QueueSize = Number(payload["queueSize"]); + networkStake.TotalStaked = new BigNumber(payload["totalStaked"]); + + return networkStake; + } +} diff --git a/src-network-providers/networkStatus.ts b/src-network-providers/networkStatus.ts new file mode 100644 index 00000000..ec42a479 --- /dev/null +++ b/src-network-providers/networkStatus.ts @@ -0,0 +1,82 @@ +/** + * An object holding network status configuration parameters. + */ +export class NetworkStatus { + private static default: NetworkStatus; + + /** + * The current round. + */ + public CurrentRound: number; + + /** + * The epoch number. + */ + public EpochNumber: number; + + /** + * The Highest final nonce. + */ + public HighestFinalNonce: number; + + /** + * The erd nonce. + */ + public Nonce: number; + + /** + * The nonce at epoch start. + */ + public NonceAtEpochStart: number; + + /** + * The nonces passed in current epoch. + */ + public NoncesPassedInCurrentEpoch: number; + + /** + * The round at epoch start + */ + public RoundAtEpochStart: number; + + /** + * The rounds passed in current epoch + */ + public RoundsPassedInCurrentEpoch: number; + + /** + * The rounds per epoch + */ + public RoundsPerEpoch: number; + + constructor() { + this.CurrentRound = 0; + this.EpochNumber = 0; + this.HighestFinalNonce = 0; + this.Nonce = 0; + this.NonceAtEpochStart = 0; + this.NoncesPassedInCurrentEpoch = 0; + this.RoundAtEpochStart = 0; + this.RoundsPassedInCurrentEpoch = 0; + this.RoundsPerEpoch = 0; + } + + /** + * Constructs a configuration object from a HTTP response (as returned by the provider). + */ + static fromHttpResponse(payload: any): NetworkStatus { + let networkStatus = new NetworkStatus(); + + networkStatus.CurrentRound = Number(payload["erd_current_round"]); + networkStatus.EpochNumber = Number(payload["erd_epoch_number"]); + networkStatus.HighestFinalNonce = Number(payload["erd_highest_final_nonce"]); + networkStatus.Nonce = Number(payload["erd_nonce"]); + networkStatus.NonceAtEpochStart = Number(payload["erd_nonce_at_epoch_start"]); + networkStatus.NoncesPassedInCurrentEpoch = Number(payload["erd_nonces_passed_in_current_epoch"]); + networkStatus.RoundAtEpochStart = Number(payload["erd_round_at_epoch_start"]); + networkStatus.RoundsPassedInCurrentEpoch = Number(payload["erd_rounds_passed_in_current_epoch"]); + networkStatus.RoundsPerEpoch = Number(payload["erd_rounds_per_epoch"]); + + return networkStatus; + } +} diff --git a/src-network-providers/pairs.ts b/src-network-providers/pairs.ts new file mode 100644 index 00000000..723c25b7 --- /dev/null +++ b/src-network-providers/pairs.ts @@ -0,0 +1,55 @@ +import {Address} from "./primitives"; +import {IAddress} from "./interface"; +import BigNumber from "bignumber.js"; + +export class PairOnNetwork { + address: IAddress = new Address(""); + id: string = ""; + symbol: string = ""; + name: string = ""; + price: BigNumber = new BigNumber(0); + baseId: string = ""; + basePrice: BigNumber = new BigNumber(0); + baseSymbol: string = ""; + baseName: string = ""; + quoteId: string = ""; + quotePrice: BigNumber = new BigNumber(0); + quoteSymbol: string = ""; + quoteName: string = ""; + totalValue: BigNumber = new BigNumber(0); + volume24h: BigNumber = new BigNumber(0); + state: string = ""; + type: string = ""; + + rawResponse: any = {}; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromApiHttpResponse(payload: any): PairOnNetwork { + let result = new PairOnNetwork(); + + result.address = new Address(payload.address || ""); + result.id = payload.id || ""; + result.symbol = payload.symbol || ""; + result.name = payload.name || ""; + result.price = new BigNumber(payload.price || 0); + result.baseId = payload.baseId || ""; + result.basePrice = new BigNumber(payload.basePrice || 0); + result.baseSymbol = payload.baseSymbol || ""; + result.baseName = payload.baseName || ""; + result.quoteId = payload.quoteId || ""; + result.quotePrice = new BigNumber(payload.quotePrice || 0); + result.quoteSymbol = payload.quoteSymbol || ""; + result.quoteName = payload.quoteName || ""; + result.totalValue = new BigNumber(payload.totalValue || 0); + result.volume24h = new BigNumber(payload.volume24h || 0); + result.state = payload.state || ""; + result.type = payload.type || ""; + + result.rawResponse = payload; + + return result; + } +} diff --git a/src-network-providers/primitives.spec.ts b/src-network-providers/primitives.spec.ts new file mode 100644 index 00000000..202a9dfe --- /dev/null +++ b/src-network-providers/primitives.spec.ts @@ -0,0 +1,17 @@ +import { assert } from "chai"; +import { Address } from "./primitives"; + +describe("test primitives", function () { + it("should create address from bech32 and from pubkey", async function () { + let aliceBech32 = "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"; + let bobBech32 = "erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7ztyrnpxrruqzu66jx"; + let alicePubkey = Buffer.from("0139472eff6886771a982f3083da5d421f24c29181e63888228dc81ca60d69e1", "hex"); + let bobPubkey = Buffer.from("8049d639e5a6980d1cd2392abcce41029cda74a1563523a202f09641cc2618f8", "hex"); + + assert.equal(new Address(aliceBech32).bech32(), Address.fromPubkey(alicePubkey).bech32()); + assert.equal(new Address(bobBech32).bech32(), Address.fromPubkey(bobPubkey).bech32()); + assert.equal(new Address(aliceBech32).toString(), aliceBech32); + assert.equal(new Address(bobBech32).toString(), bobBech32); + }); +}); + diff --git a/src-network-providers/primitives.ts b/src-network-providers/primitives.ts new file mode 100644 index 00000000..ed50b4ef --- /dev/null +++ b/src-network-providers/primitives.ts @@ -0,0 +1,66 @@ +import * as bech32 from "bech32"; +import { IAddress } from "./interface"; + +/** + * The human-readable-part of the bech32 addresses. + */ +const HRP = "erd"; + +export class Address implements IAddress { + private readonly value: string; + + constructor(value: string) { + this.value = value; + } + + static fromPubkey(pubkey: Buffer): IAddress { + let words = bech32.toWords(pubkey); + let address = bech32.encode(HRP, words); + return new Address(address); + } + + bech32(): string { + return this.value; + } + + toString() { + return this.bech32(); + } +} + +export class Nonce { + private readonly value: number; + + constructor(value: number) { + this.value = value; + } + + valueOf(): number { + return this.value; + } + + hex(): string { + return numberToPaddedHex(this.value); + } +} + +export function numberToPaddedHex(value: number) { + let hex = value.toString(16); + return zeroPadStringIfOddLength(hex); +} + +export function isPaddedHex(input: string) { + input = input || ""; + let decodedThenEncoded = Buffer.from(input, "hex").toString("hex"); + return input.toUpperCase() == decodedThenEncoded.toUpperCase(); +} + +export function zeroPadStringIfOddLength(input: string): string { + input = input || ""; + + if (input.length % 2 == 1) { + return "0" + input; + } + + return input; +} diff --git a/src-network-providers/providers.dev.net.spec.ts b/src-network-providers/providers.dev.net.spec.ts new file mode 100644 index 00000000..8c603c43 --- /dev/null +++ b/src-network-providers/providers.dev.net.spec.ts @@ -0,0 +1,411 @@ +import { AxiosHeaders } from "axios"; +import { assert } from "chai"; +import { ApiNetworkProvider } from "./apiNetworkProvider"; +import { INetworkProvider, ITransactionNext } from "./interface"; +import { Address } from "./primitives"; +import { ProxyNetworkProvider } from "./proxyNetworkProvider"; +import { MockQuery } from "./testscommon/dummyQuery"; +import { NonFungibleTokenOfAccountOnNetwork } from "./tokens"; +import { TransactionEventData } from "./transactionEvents"; +import { TransactionOnNetwork } from "./transactions"; + +describe("test network providers on devnet: Proxy and API", function () { + let alice = new Address("erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + let carol = new Address("erd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaq6mjse8"); + let dan = new Address("erd1kyaqzaprcdnv4luvanah0gfxzzsnpaygsy6pytrexll2urtd05ts9vegu7"); + const MAX_NUMBER_OF_ITEMS_BY_DEFAULT = 20; + + let apiProvider: INetworkProvider = new ApiNetworkProvider("https://devnet-api.multiversx.com", { timeout: 10000, clientName: 'test' }); + let proxyProvider: INetworkProvider = new ProxyNetworkProvider("https://devnet-gateway.multiversx.com", { timeout: 10000, clientName: 'test' }); + + it("should create providers without configuration", async function () { + const apiProviderWithoutConfig = new ApiNetworkProvider("https://devnet-api.multiversx.com"); + const proxyProviderWithoutConfig = new ProxyNetworkProvider("https://devnet-gateway.multiversx.com"); + + const apiResponse = await apiProviderWithoutConfig.getNetworkConfig(); + const proxyResponse = await proxyProviderWithoutConfig.getNetworkConfig(); + + assert.equal(apiResponse.ChainID, "D"); + assert.equal(proxyResponse.ChainID, "D"); + }); + + it("should have same response for getNetworkConfig()", async function () { + let apiResponse = await apiProvider.getNetworkConfig(); + let proxyResponse = await proxyProvider.getNetworkConfig(); + + assert.deepEqual(apiResponse, proxyResponse); + }); + + it("should add userAgent unknown for clientName when no clientName passed", async function () { + const expectedApiUserAgent = "multiversx-sdk/api/unknown" + const expectedProxyUserAgent = "multiversx-sdk/proxy/unknown" + + let localApiProvider: any = new ApiNetworkProvider("https://devnet-api.multiversx.com", { timeout: 10000 }); + let localProxyProvider: any = new ProxyNetworkProvider("https://devnet-gateway.multiversx.com", { timeout: 10000 }); + + assert.equal(localApiProvider.config.headers.getUserAgent(), expectedApiUserAgent); + assert.equal(localProxyProvider.config.headers.getUserAgent(), expectedProxyUserAgent); + }); + + it("should set userAgent with specified clientName ", async function () { + const expectedApiUserAgent = "multiversx-sdk/api/test" + const expectedProxyUserAgent = "multiversx-sdk/proxy/test" + + let localApiProvider: any = new ApiNetworkProvider("https://devnet-api.multiversx.com", { timeout: 10000, clientName: 'test' }); + let localProxyProvider: any = new ProxyNetworkProvider("https://devnet-gateway.multiversx.com", { timeout: 10000, clientName: 'test' }); + + assert.equal(localApiProvider.config.headers.getUserAgent(), expectedApiUserAgent); + assert.equal(localProxyProvider.config.headers.getUserAgent(), expectedProxyUserAgent); + }); + + it("should keep the set userAgent and add the sdk to it", async function () { + const expectedApiUserAgent = "Client-info multiversx-sdk/api/test" + const expectedProxyUserAgent = "Client-info multiversx-sdk/proxy/test" + + let localApiProvider: any = new ApiNetworkProvider("https://devnet-api.multiversx.com", { timeout: 10000, headers: new AxiosHeaders({ "User-Agent": "Client-info" }), clientName: 'test' }); + let localProxyProvider: any = new ProxyNetworkProvider("https://devnet-gateway.multiversx.com", { timeout: 10000, headers: new AxiosHeaders({ "User-Agent": "Client-info" }), clientName: 'test' }); + + assert.equal(localApiProvider.config.headers.getUserAgent(), expectedApiUserAgent); + assert.equal(localProxyProvider.config.headers.getUserAgent(), expectedProxyUserAgent); + }); + + it("should have same response for getNetworkStatus()", async function () { + let apiResponse = await apiProvider.getNetworkStatus(); + let proxyResponse = await proxyProvider.getNetworkStatus(); + + assert.equal(apiResponse.EpochNumber, proxyResponse.EpochNumber); + assert.equal(apiResponse.NonceAtEpochStart, proxyResponse.NonceAtEpochStart); + assert.equal(apiResponse.RoundAtEpochStart, proxyResponse.RoundAtEpochStart); + assert.equal(apiResponse.RoundsPerEpoch, proxyResponse.RoundsPerEpoch); + // done this way because the nonces may change until both requests are executed + assert.approximately(apiResponse.CurrentRound, proxyResponse.CurrentRound, 1); + assert.approximately(apiResponse.HighestFinalNonce, proxyResponse.HighestFinalNonce, 1); + assert.approximately(apiResponse.Nonce, proxyResponse.Nonce, 1); + assert.approximately(apiResponse.NoncesPassedInCurrentEpoch, proxyResponse.NoncesPassedInCurrentEpoch, 1); + }); + + // TODO: Enable test after implementing ProxyNetworkProvider.getNetworkStakeStatistics(). + it.skip("should have same response for getNetworkStakeStatistics()", async function () { + let apiResponse = await apiProvider.getNetworkStakeStatistics(); + let proxyResponse = await proxyProvider.getNetworkStakeStatistics(); + + assert.deepEqual(apiResponse, proxyResponse); + }); + + // TODO: Enable test after implementing ProxyNetworkProvider.getNetworkGeneralStatistics(). + it.skip("should have same response for getNetworkGeneralStatistics()", async function () { + let apiResponse = await apiProvider.getNetworkGeneralStatistics(); + let proxyResponse = await proxyProvider.getNetworkGeneralStatistics(); + + assert.deepEqual(apiResponse, proxyResponse); + }); + + it("should have same response for getAccount()", async function () { + let apiResponse = await apiProvider.getAccount(alice); + let proxyResponse = await proxyProvider.getAccount(alice); + + assert.deepEqual(apiResponse, proxyResponse); + }); + + it("should have same response for getFungibleTokensOfAccount(), getFungibleTokenOfAccount()", async function () { + this.timeout(30000); + + for (const user of [carol, dan]) { + let apiResponse = (await apiProvider.getFungibleTokensOfAccount(user)).slice(0, MAX_NUMBER_OF_ITEMS_BY_DEFAULT); + let proxyResponse = (await proxyProvider.getFungibleTokensOfAccount(user)).slice(0, MAX_NUMBER_OF_ITEMS_BY_DEFAULT); + + for (let i = 0; i < apiResponse.length; i++) { + assert.equal(apiResponse[i].identifier, proxyResponse[i].identifier); + assert.equal(apiResponse[i].balance.valueOf, proxyResponse[i].balance.valueOf); + } + } + }); + + it("should have same response for getNonFungibleTokensOfAccount(), getNonFungibleTokenOfAccount", async function () { + this.timeout(30000); + + let apiResponse = (await apiProvider.getNonFungibleTokensOfAccount(dan)).slice(0, MAX_NUMBER_OF_ITEMS_BY_DEFAULT); + let proxyResponse = (await proxyProvider.getNonFungibleTokensOfAccount(dan)).slice(0, MAX_NUMBER_OF_ITEMS_BY_DEFAULT); + + assert.isTrue(apiResponse.length > 0, "For the sake of the test, there should be at least one item."); + assert.equal(apiResponse.length, proxyResponse.length); + + for (let i = 0; i < apiResponse.length; i++) { + removeInconsistencyForNonFungibleTokenOfAccount(apiResponse[i], proxyResponse[i]); + } + + assert.deepEqual(apiResponse, proxyResponse); + + const item = apiResponse[0]; + let apiItemResponse = await apiProvider.getNonFungibleTokenOfAccount(dan, item.collection, item.nonce); + let proxyItemResponse = await proxyProvider.getNonFungibleTokenOfAccount(dan, item.collection, item.nonce); + + removeInconsistencyForNonFungibleTokenOfAccount(apiItemResponse, proxyItemResponse); + assert.deepEqual(apiResponse, proxyResponse, `user: ${dan.bech32()}, token: ${item.identifier}`); + }); + + // TODO: Strive to have as little differences as possible between Proxy and API. + function removeInconsistencyForNonFungibleTokenOfAccount(apiResponse: NonFungibleTokenOfAccountOnNetwork, proxyResponse: NonFungibleTokenOfAccountOnNetwork) { + // unset unconsistent fields + apiResponse.type = ""; + proxyResponse.type = ""; + apiResponse.name = ""; + proxyResponse.name = ""; + apiResponse.decimals = 0; + proxyResponse.decimals = 0; + } + + it("should be able to send transaction(s)", async function () { + this.timeout(5000); + + const txs = [ + { + toSendable: function () { + return { + "nonce": 42, + "value": "1", + "receiver": "erd1testnlersh4z0wsv8kjx39me4rmnvjkwu8dsaea7ukdvvc9z396qykv7z7", + "sender": "erd15x2panzqvfxul2lvstfrmdcl5t4frnsylfrhng8uunwdssxw4y9succ9sq", + "gasPrice": 1000000000, + "gasLimit": 50000, + "chainID": "D", + "version": 1, + "signature": "c8eb539e486db7d703d8c70cab3b7679113f77c4685d8fcc94db027ceacc6b8605115034355386dffd7aa12e63dbefa03251a2f1b1d971f52250187298d12900" + } + } + }, + { + toSendable: function () { + return { + "nonce": 43, + "value": "1", + "receiver": "erd1testnlersh4z0wsv8kjx39me4rmnvjkwu8dsaea7ukdvvc9z396qykv7z7", + "sender": "erd15x2panzqvfxul2lvstfrmdcl5t4frnsylfrhng8uunwdssxw4y9succ9sq", + "gasPrice": 1000000000, + "gasLimit": 50000, + "chainID": "D", + "version": 1, + "signature": "9c4c22d0ae1b5a10c39583a5ab9020b00b27aa69d4ac8ab4922620dbf0df4036ed890f9946d38a9d0c85d6ac485c0d9b2eac0005e752f249fd0ad863b0471d02" + } + } + }, + { + toSendable: function () { + return { + "nonce": 44 + } + } + } + ]; + + const expectedHashes = [ + "6e2fa63ea02937f00d7549f3e4eb9af241e4ac13027aa65a5300816163626c01", + "37d7e84313a5baea2a61c6ab10bb29b52bc54f7ac9e3918a9faeb1e08f42081c", + null + ] + + assert.equal(await apiProvider.sendTransaction(txs[0]), expectedHashes[0]); + assert.equal(await proxyProvider.sendTransaction(txs[1]), expectedHashes[1]); + + assert.deepEqual(await apiProvider.sendTransactions(txs), expectedHashes); + assert.deepEqual(await proxyProvider.sendTransactions(txs), expectedHashes); + }); + + it("should have same response for getTransaction()", async function () { + this.timeout(20000); + + let hashes = [ + "08acf8cbd71306a56eb58f9593cb2e23f109c94e27acdd906c82a5c3a5f84d9d", + "410efb1db2ab86678b8dbc503beb695b5b7d52754fb0de86c09cbb433de5f6a8" + ]; + + for (const hash of hashes) { + let apiResponse = await apiProvider.getTransaction(hash); + let proxyResponse = await proxyProvider.getTransaction(hash, true); + + ignoreKnownTransactionDifferencesBetweenProviders(apiResponse, proxyResponse); + assert.deepEqual(apiResponse, proxyResponse, `transaction: ${hash}`); + + // Also assert completion + assert.isTrue(apiResponse.isCompleted); + assert.isTrue(proxyResponse.isCompleted); + } + }); + + // TODO: Strive to have as little differences as possible between Proxy and API. + function ignoreKnownTransactionDifferencesBetweenProviders(apiResponse: TransactionOnNetwork, proxyResponse: TransactionOnNetwork) { + // Proxy and API exhibit differences in the "function" field, in case of move-balance transactions. + apiResponse.function = proxyResponse.function + + // Ignore fields which are not present on API response: + proxyResponse.epoch = 0; + proxyResponse.blockNonce = 0; + proxyResponse.hyperblockNonce = 0; + proxyResponse.hyperblockHash = ""; + } + + it("should have the same response for transactions with events", async function () { + const hash = "1b04eb849cf87f2d3086c77b4b825d126437b88014327bbf01437476751cb040"; + + let apiResponse = await apiProvider.getTransaction(hash); + let proxyResponse = await proxyProvider.getTransaction(hash); + + assert.exists(apiResponse.logs); + assert.exists(proxyResponse.logs); + assert.exists(apiResponse.logs.events) + assert.exists(proxyResponse.logs.events) + assert.equal(apiResponse.logs.events[0].topics[0].hex(), "414c4943452d353632376631") + assert.equal(apiResponse.logs.events[0].topics[1].hex(), "") + assert.equal(apiResponse.logs.events[0].topics[2].hex(), "01") + assert.equal(apiResponse.logs.events[0].topics[3].hex(), "0000000000000000050032e141d21536e2dfc3d64b9e7dd0c2c53f201dc469e1") + assert.equal(proxyResponse.logs.events[0].topics[0].hex(), "414c4943452d353632376631") + assert.equal(proxyResponse.logs.events[0].topics[1].hex(), "") + assert.equal(proxyResponse.logs.events[0].topics[2].hex(), "01") + assert.equal(proxyResponse.logs.events[0].topics[3].hex(), "0000000000000000050032e141d21536e2dfc3d64b9e7dd0c2c53f201dc469e1") + }); + + it("should have same response for getTransactionStatus()", async function () { + this.timeout(20000); + + let hashes = [ + "08acf8cbd71306a56eb58f9593cb2e23f109c94e27acdd906c82a5c3a5f84d9d", + "410efb1db2ab86678b8dbc503beb695b5b7d52754fb0de86c09cbb433de5f6a8" + ]; + + for (const hash of hashes) { + let apiResponse = await apiProvider.getTransactionStatus(hash); + let proxyResponse = await proxyProvider.getTransactionStatus(hash); + + assert.deepEqual(apiResponse, proxyResponse, `transaction: ${hash}`); + } + }); + + it("should have same response for getDefinitionOfFungibleToken()", async function () { + this.timeout(10000); + + let identifier = "CHOCOLATE-daf625"; + + let apiResponse = await apiProvider.getDefinitionOfFungibleToken(identifier); + let proxyResponse = await proxyProvider.getDefinitionOfFungibleToken(identifier); + + // Assets are only present on API responses, thus we ignore them for comparison. + apiResponse.assets = {}; + + assert.equal(apiResponse.identifier, identifier); + assert.deepEqual(apiResponse, proxyResponse); + + }); + + it("should have same response for getDefinitionOfTokenCollection()", async function () { + this.timeout(10000); + + let collections = ["TEST-37adcf"]; + + for (const collection of collections) { + let apiResponse = await apiProvider.getDefinitionOfTokenCollection(collection); + let proxyResponse = await proxyProvider.getDefinitionOfTokenCollection(collection); + + assert.equal(apiResponse.collection, collection); + assert.deepEqual(apiResponse, proxyResponse); + } + }); + + it("should have same response for getNonFungibleToken()", async function () { + this.timeout(10000); + + let tokens = [{ id: "TEST-37adcf", nonce: 1 }]; + + for (const token of tokens) { + let apiResponse = await apiProvider.getNonFungibleToken(token.id, token.nonce); + + assert.equal(apiResponse.collection, token.id); + + // TODO: Uncomment after implementing the function in the proxy provider. + // let proxyResponse = await proxyProvider.getNonFungibleToken(token.id, token.nonce); + // assert.deepEqual(apiResponse, proxyResponse); + } + }); + + it("should have same response for queryContract()", async function () { + this.timeout(10000); + + // Query: get sum (of adder contract) + let query = new MockQuery({ + address: new Address("erd1qqqqqqqqqqqqqpgqfzydqmdw7m2vazsp6u5p95yxz76t2p9rd8ss0zp9ts"), + func: "getSum" + }); + + let apiResponse = await apiProvider.queryContract(query); + let proxyResponse = await proxyProvider.queryContract(query); + + // Ignore "gasUsed" due to numerical imprecision (API). + apiResponse.gasUsed = 0; + proxyResponse.gasUsed = 0; + + assert.deepEqual(apiResponse, proxyResponse); + assert.deepEqual(apiResponse.getReturnDataParts(), proxyResponse.getReturnDataParts()); + }); + + it("should handle events 'data' and 'additionalData'", async function () { + this.timeout(50000); + + const apiResponse = await apiProvider.getTransaction("a419271407a2ec217739811805e3a751e30dbc72ae0777e3b4c825f036995184"); + const proxyResponse = await proxyProvider.getTransaction("a419271407a2ec217739811805e3a751e30dbc72ae0777e3b4c825f036995184"); + + assert.equal(apiResponse.logs.events[0].data, Buffer.from("test").toString()); + assert.equal(proxyResponse.logs.events[0].data, Buffer.from("test").toString()); + + assert.deepEqual(apiResponse.logs.events[0].dataPayload, TransactionEventData.fromBase64("dGVzdA==")); + assert.deepEqual(proxyResponse.logs.events[0].dataPayload, TransactionEventData.fromBase64("dGVzdA==")); + + assert.deepEqual(apiResponse.logs.events[0].additionalData, [TransactionEventData.fromBase64("dGVzdA==")]); + assert.deepEqual(proxyResponse.logs.events[0].additionalData, [TransactionEventData.fromBase64("dGVzdA==")]); + }); + + it("should send both `Transaction` and `TransactionNext`", async function () { + this.timeout(50000); + + const transaction = { + toSendable: function () { + return { + "nonce": 7, + "value": "0", + "receiver": "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th", + "sender": "erd1zztjf9fhwvuvquzsllknq4qcmffwad6n0hjtn5dyzytr5tgz7uas0mkgrq", + "gasPrice": 1000000000, + "gasLimit": 50000, + "chainID": "D", + "version": 2, + "signature": "149f1d8296efcb9489c5b3142ae659aacfa3a7daef3645f1d3747a96dc9cee377070dd8b83b322997c15ba3c305ac18daaee0fd25760eba334b14a9272b34802" + } + } + } + + const transactionNext: ITransactionNext = { + nonce: BigInt(8), + value: BigInt(0), + receiver: "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th", + sender: "erd1zztjf9fhwvuvquzsllknq4qcmffwad6n0hjtn5dyzytr5tgz7uas0mkgrq", + data: new Uint8Array(Buffer.from("test")), + gasPrice: BigInt(1000000000), + gasLimit: BigInt(80000), + chainID: "D", + version: 2, + signature: Buffer.from("3fa42d97b4f85442850340a11411a3cbd63885e06ff3f84c7a75d0ef59c780f7a18aa4f331cf460300bc8bd99352aea10b7c3bc17e40287337ae9f9842470205", "hex"), + senderUsername: "", + receiverUsername: "", + guardian: "", + guardianSignature: new Uint8Array(), + options: 0 + } + + const apiLegacyTxHash = await apiProvider.sendTransaction(transaction); + const apiTxNextHash = await apiProvider.sendTransaction(transactionNext); + + const proxyLegacyTxHash = await proxyProvider.sendTransaction(transaction); + const proxyTxNextHash = await proxyProvider.sendTransaction(transactionNext); + + assert.equal(apiLegacyTxHash, proxyLegacyTxHash); + assert.equal(apiTxNextHash, proxyTxNextHash); + }); +}); diff --git a/src-network-providers/proxyNetworkProvider.ts b/src-network-providers/proxyNetworkProvider.ts new file mode 100644 index 00000000..c58e4bf1 --- /dev/null +++ b/src-network-providers/proxyNetworkProvider.ts @@ -0,0 +1,254 @@ +import axios from "axios"; +import { AccountOnNetwork, GuardianData } from "./accounts"; +import { defaultAxiosConfig } from "./config"; +import { EsdtContractAddress, BaseUserAgent } from "./constants"; +import { ContractQueryRequest } from "./contractQueryRequest"; +import { ContractQueryResponse } from "./contractQueryResponse"; +import { ErrContractQuery, ErrNetworkProvider } from "./errors"; +import { IAddress, IContractQuery, INetworkProvider, IPagination, ITransaction, ITransactionNext } from "./interface"; +import { NetworkConfig } from "./networkConfig"; +import { NetworkGeneralStatistics } from "./networkGeneralStatistics"; +import { NetworkStake } from "./networkStake"; +import { NetworkStatus } from "./networkStatus"; +import { DefinitionOfFungibleTokenOnNetwork, DefinitionOfTokenCollectionOnNetwork } from "./tokenDefinitions"; +import { FungibleTokenOfAccountOnNetwork, NonFungibleTokenOfAccountOnNetwork } from "./tokens"; +import { TransactionOnNetwork, prepareTransactionForBroadcasting } from "./transactions"; +import { TransactionStatus } from "./transactionStatus"; +import { extendUserAgent } from "./userAgent"; +import { NetworkProviderConfig } from "./networkProviderConfig"; + +// TODO: Find & remove duplicate code between "ProxyNetworkProvider" and "ApiNetworkProvider". +export class ProxyNetworkProvider implements INetworkProvider { + private url: string; + private config: NetworkProviderConfig; + private userAgentPrefix = `${BaseUserAgent}/proxy` + + constructor(url: string, config?: NetworkProviderConfig) { + this.url = url; + this.config = { ...defaultAxiosConfig, ...config }; + extendUserAgent(this.userAgentPrefix, this.config); + } + + async getNetworkConfig(): Promise { + let response = await this.doGetGeneric("network/config"); + let networkConfig = NetworkConfig.fromHttpResponse(response.config); + return networkConfig; + } + + async getNetworkStatus(): Promise { + let response = await this.doGetGeneric("network/status/4294967295"); + let networkStatus = NetworkStatus.fromHttpResponse(response.status); + return networkStatus; + } + + async getNetworkStakeStatistics(): Promise { + // TODO: Implement wrt.: + // https://github.com/multiversx/mx-api-service/blob/main/src/endpoints/stake/stake.service.ts + throw new Error("Method not implemented."); + } + + async getNetworkGeneralStatistics(): Promise { + // TODO: Implement wrt. (full implementation may not be possible): + // https://github.com/multiversx/mx-api-service/blob/main/src/endpoints/network/network.service.ts + throw new Error("Method not implemented."); + } + + async getAccount(address: IAddress): Promise { + let response = await this.doGetGeneric(`address/${address.bech32()}`); + let account = AccountOnNetwork.fromHttpResponse(response.account); + return account; + } + + async getGuardianData(address: IAddress): Promise { + const response = await this.doGetGeneric(`address/${address.bech32()}/guardian-data`); + const accountGuardian = GuardianData.fromHttpResponse(response.guardianData); + return accountGuardian; + } + + async getFungibleTokensOfAccount(address: IAddress, _pagination?: IPagination): Promise { + let url = `address/${address.bech32()}/esdt`; + let response = await this.doGetGeneric(url); + let responseItems: any[] = Object.values(response.esdts); + // Skip NFTs / SFTs. + let responseItemsFiltered = responseItems.filter(item => !item.nonce); + let tokens = responseItemsFiltered.map(item => FungibleTokenOfAccountOnNetwork.fromHttpResponse(item)); + + // TODO: Fix sorting + tokens.sort((a, b) => a.identifier.localeCompare(b.identifier)); + return tokens; + } + + async getNonFungibleTokensOfAccount(address: IAddress, _pagination?: IPagination): Promise { + let url = `address/${address.bech32()}/esdt`; + let response = await this.doGetGeneric(url); + let responseItems: any[] = Object.values(response.esdts); + // Skip fungible tokens. + let responseItemsFiltered = responseItems.filter(item => item.nonce >= 0); + let tokens = responseItemsFiltered.map(item => NonFungibleTokenOfAccountOnNetwork.fromProxyHttpResponse(item)); + + // TODO: Fix sorting + tokens.sort((a, b) => a.identifier.localeCompare(b.identifier)); + return tokens; + } + + async getFungibleTokenOfAccount(address: IAddress, tokenIdentifier: string): Promise { + let response = await this.doGetGeneric(`address/${address.bech32()}/esdt/${tokenIdentifier}`); + let tokenData = FungibleTokenOfAccountOnNetwork.fromHttpResponse(response.tokenData); + return tokenData; + } + + async getNonFungibleTokenOfAccount(address: IAddress, collection: string, nonce: number): Promise { + let response = await this.doGetGeneric(`address/${address.bech32()}/nft/${collection}/nonce/${nonce.valueOf()}`); + let tokenData = NonFungibleTokenOfAccountOnNetwork.fromProxyHttpResponseByNonce(response.tokenData); + return tokenData; + } + + async getTransaction(txHash: string, withProcessStatus?: boolean): Promise { + let processStatusPromise: Promise | undefined; + + if (withProcessStatus === true) { + processStatusPromise = this.getTransactionStatus(txHash); + } + + let url = this.buildUrlWithQueryParameters(`transaction/${txHash}`, { withResults: "true" }); + let response = await this.doGetGeneric(url); + + if (processStatusPromise) { + const processStatus = await processStatusPromise; + return TransactionOnNetwork.fromProxyHttpResponse(txHash, response.transaction, processStatus); + } + return TransactionOnNetwork.fromProxyHttpResponse(txHash, response.transaction); + } + + async getTransactionStatus(txHash: string): Promise { + let response = await this.doGetGeneric(`transaction/${txHash}/process-status`); + let status = new TransactionStatus(response.status); + return status; + } + + async sendTransaction(tx: ITransaction | ITransactionNext): Promise { + const transaction = prepareTransactionForBroadcasting(tx); + const response = await this.doPostGeneric("transaction/send", transaction); + return response.txHash; + } + + async sendTransactions(txs: (ITransaction | ITransactionNext)[]): Promise { + const data = (txs).map((tx) => prepareTransactionForBroadcasting(tx)); + + const response = await this.doPostGeneric("transaction/send-multiple", data); + const hashes = Array(txs.length).fill(null); + + for (let i = 0; i < txs.length; i++) { + hashes[i] = response.txsHashes[i.toString()] || null; + } + + return hashes; + } + + async simulateTransaction(tx: ITransaction | ITransactionNext): Promise { + const transaction = prepareTransactionForBroadcasting(tx); + const response = await this.doPostGeneric("transaction/simulate", transaction); + return response; + } + + async queryContract(query: IContractQuery): Promise { + try { + let request = new ContractQueryRequest(query).toHttpRequest(); + let response = await this.doPostGeneric("vm-values/query", request); + return ContractQueryResponse.fromHttpResponse(response.data); + } catch (error: any) { + throw new ErrContractQuery(error); + } + } + + async getDefinitionOfFungibleToken(tokenIdentifier: string): Promise { + let properties = await this.getTokenProperties(tokenIdentifier); + let definition = DefinitionOfFungibleTokenOnNetwork.fromResponseOfGetTokenProperties(tokenIdentifier, properties); + return definition; + } + + private async getTokenProperties(identifier: string): Promise { + let encodedIdentifier = Buffer.from(identifier).toString("hex"); + + let queryResponse = await this.queryContract({ + address: EsdtContractAddress, + func: "getTokenProperties", + getEncodedArguments: () => [encodedIdentifier] + }); + + let properties = queryResponse.getReturnDataParts(); + return properties; + } + + async getDefinitionOfTokenCollection(collection: string): Promise { + let properties = await this.getTokenProperties(collection); + let definition = DefinitionOfTokenCollectionOnNetwork.fromResponseOfGetTokenProperties(collection, properties); + return definition; + } + + async getNonFungibleToken(_collection: string, _nonce: number): Promise { + throw new Error("Method not implemented."); + } + + async doGetGeneric(resourceUrl: string): Promise { + let response = await this.doGet(resourceUrl); + return response; + } + + async doPostGeneric(resourceUrl: string, payload: any): Promise { + let response = await this.doPost(resourceUrl, payload); + return response; + } + + private async doGet(resourceUrl: string): Promise { + let url = `${this.url}/${resourceUrl}`; + + try { + let response = await axios.get(url, this.config); + let payload = response.data.data; + return payload; + } catch (error) { + this.handleApiError(error, resourceUrl); + } + } + + private async doPost(resourceUrl: string, payload: any): Promise { + let url = `${this.url}/${resourceUrl}`; + + try { + let response = await axios.post(url, payload, { + ...this.config, + headers: { + "Content-Type": "application/json", + ...this.config.headers, + }, + }); + let responsePayload = response.data.data; + return responsePayload; + } catch (error) { + this.handleApiError(error, resourceUrl); + } + } + + private buildUrlWithQueryParameters(endpoint: string, params: Record): string { + let searchParams = new URLSearchParams(); + + for (let [key, value] of Object.entries(params)) { + if (value) { + searchParams.append(key, value); + } + } + + return `${endpoint}?${searchParams.toString()}`; + } + + private handleApiError(error: any, resourceUrl: string) { + if (!error.response) { + throw new ErrNetworkProvider(resourceUrl, error.toString(), error); + } + + const errorData = error.response.data; + const originalErrorMessage = errorData.message || errorData.error || JSON.stringify(errorData); + throw new ErrNetworkProvider(resourceUrl, originalErrorMessage, error); + } +} diff --git a/src-network-providers/serialization.spec.ts b/src-network-providers/serialization.spec.ts new file mode 100644 index 00000000..6ffc8309 --- /dev/null +++ b/src-network-providers/serialization.spec.ts @@ -0,0 +1,16 @@ +import assert from "assert"; + +describe("test JSON serialization", function () { + it("should not deserialize", async function () { + const JSONbig = require("json-bigint"); + const data = `{"Costum":{"foo_constructor":1}}`; + assert.throws(() => JSONbig.parse(data)); + }); + + it("should deserialize", async function () { + const JSONbig = require("json-bigint")({ constructorAction: 'ignore' }); + const data = `{"Costum":{"foo_constructor":1}}`; + JSONbig.parse(data); + }); +}); + diff --git a/src-network-providers/testscommon/dummyQuery.ts b/src-network-providers/testscommon/dummyQuery.ts new file mode 100644 index 00000000..a0cdac9c --- /dev/null +++ b/src-network-providers/testscommon/dummyQuery.ts @@ -0,0 +1,18 @@ +import { IAddress, IContractQuery } from "../interface"; +import { Address } from "../primitives"; + +export class MockQuery implements IContractQuery { + caller: IAddress = new Address(""); + address: IAddress = new Address(""); + func: string = ""; + args: string[] = []; + value: string = ""; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + getEncodedArguments(): string[] { + return this.args; + } +} diff --git a/src-network-providers/tokenDefinitions.ts b/src-network-providers/tokenDefinitions.ts new file mode 100644 index 00000000..63bfa523 --- /dev/null +++ b/src-network-providers/tokenDefinitions.ts @@ -0,0 +1,157 @@ +import BigNumber from "bignumber.js"; +import { IAddress } from "./interface"; +import { Address } from "./primitives"; + +export class DefinitionOfFungibleTokenOnNetwork { + identifier: string = ""; + name: string = ""; + ticker: string = ""; + owner: IAddress = new Address(""); + decimals: number = 0; + supply: BigNumber = new BigNumber(0); + isPaused: boolean = false; + canUpgrade: boolean = false; + canMint: boolean = false; + canBurn: boolean = false; + canChangeOwner: boolean = false; + canPause: boolean = false; + canFreeze: boolean = false; + canWipe: boolean = false; + canAddSpecialRoles: boolean = false; + assets: Record = {}; + + static fromApiHttpResponse(payload: any): DefinitionOfFungibleTokenOnNetwork { + let result = new DefinitionOfFungibleTokenOnNetwork(); + + result.identifier = payload.identifier || ""; + result.name = payload.name || ""; + result.ticker = payload.ticker || ""; + result.owner = new Address(payload.owner || ""); + result.decimals = payload.decimals || 0; + result.supply = new BigNumber(payload.supply || "0"); + result.isPaused = payload.isPaused || false; + result.canUpgrade = payload.canUpgrade || false; + result.canMint = payload.canMint || false; + result.canBurn = payload.canBurn || false; + result.canChangeOwner = payload.canChangeOwner || false; + result.canPause = payload.canPause || false; + result.canFreeze = payload.canFreeze || false; + result.canWipe = payload.canWipe || false; + result.assets = payload.assets || {}; + + return result; + } + + /** + * The implementation has been moved here from the following location: + * https://github.com/multiversx/mx-sdk-js-core/blob/release/v9/src/token.ts + */ + static fromResponseOfGetTokenProperties(identifier: string, data: Buffer[]): DefinitionOfFungibleTokenOnNetwork { + let result = new DefinitionOfFungibleTokenOnNetwork(); + + let [tokenName, _tokenType, owner, supply, ...propertiesBuffers] = data; + let properties = parseTokenProperties(propertiesBuffers); + + result.identifier = identifier; + result.name = tokenName.toString(); + result.ticker = identifier; + result.owner = Address.fromPubkey(owner); + result.decimals = properties.NumDecimals.toNumber(); + result.supply = new BigNumber(supply.toString()).shiftedBy(-result.decimals); + result.isPaused = properties.IsPaused; + result.canUpgrade = properties.CanUpgrade; + result.canMint = properties.CanMint; + result.canBurn = properties.CanBurn; + result.canChangeOwner = properties.CanChangeOwner; + result.canPause = properties.CanPause; + result.canFreeze = properties.CanFreeze; + result.canWipe = properties.CanWipe; + + return result; + } +} + +export class DefinitionOfTokenCollectionOnNetwork { + collection: string = ""; + type: string = ""; + name: string = ""; + ticker: string = ""; + owner: IAddress = new Address(""); + decimals: number = 0; + canPause: boolean = false; + canFreeze: boolean = false; + canWipe: boolean = false; + canUpgrade: boolean = false; + canChangeOwner: boolean = false; + canAddSpecialRoles: boolean = false; + canTransferNftCreateRole: boolean = false; + canCreateMultiShard: boolean = false; + + static fromApiHttpResponse(payload: any): DefinitionOfTokenCollectionOnNetwork { + let result = new DefinitionOfTokenCollectionOnNetwork(); + + result.collection = payload.collection || ""; + result.type = payload.type || ""; + result.name = payload.name || ""; + result.ticker = payload.ticker || ""; + result.owner = new Address(payload.owner || ""); + result.decimals = payload.decimals || 0; + result.canPause = payload.canPause || false; + result.canFreeze = payload.canFreeze || false; + result.canWipe = payload.canWipe || false; + result.canUpgrade = payload.canUpgrade || false; + result.canAddSpecialRoles = payload.canAddSpecialRoles || false; + result.canTransferNftCreateRole = payload.canTransferNftCreateRole || false; + + return result; + } + + /** + * The implementation has been moved here from the following location: + * https://github.com/multiversx/mx-sdk-js-core/blob/release/v9/src/token.ts + */ + static fromResponseOfGetTokenProperties(collection: string, data: Buffer[]): DefinitionOfTokenCollectionOnNetwork { + let result = new DefinitionOfTokenCollectionOnNetwork(); + + let [tokenName, tokenType, owner, _, __, ...propertiesBuffers] = data; + let properties = parseTokenProperties(propertiesBuffers); + + result.collection = collection; + result.type = tokenType.toString(); + result.name = tokenName.toString(); + result.ticker = collection; + result.owner = Address.fromPubkey(owner); + result.decimals = properties.NumDecimals.toNumber() ?? 0; + result.canPause = properties.CanPause || false; + result.canFreeze = properties.CanFreeze || false; + result.canWipe = properties.CanWipe || false; + result.canUpgrade = properties.CanUpgrade || false; + result.canChangeOwner = properties.CanChangeOwner || false; + result.canAddSpecialRoles = properties.CanAddSpecialRoles || false; + result.canTransferNftCreateRole = properties.CanTransferNFTCreateRole || false; + result.canCreateMultiShard = properties.CanCreateMultiShard || false; + + return result; + } +} + +// Token properties have the following format: {PropertyName}-{PropertyValue}. +function parseTokenProperties(propertiesBuffers: Buffer[]): Record { + let properties: Record = {}; + + for (let buffer of propertiesBuffers) { + let [name, value] = buffer.toString().split("-"); + properties[name] = parseValueOfTokenProperty(value); + } + + return properties; +} + +// This only handles booleans and numbers. +function parseValueOfTokenProperty(value: string): any { + switch (value) { + case "true": return true; + case "false": return false; + default: return new BigNumber(value); + } +} diff --git a/src-network-providers/tokens.ts b/src-network-providers/tokens.ts new file mode 100644 index 00000000..855d2428 --- /dev/null +++ b/src-network-providers/tokens.ts @@ -0,0 +1,94 @@ +import { BigNumber } from "bignumber.js"; +import { Address, Nonce } from "./primitives"; +import { IAddress } from "./interface"; + +export class FungibleTokenOfAccountOnNetwork { + identifier: string = ""; + balance: BigNumber = new BigNumber(0); + rawResponse: any = {}; + + static fromHttpResponse(payload: any): FungibleTokenOfAccountOnNetwork { + let result = new FungibleTokenOfAccountOnNetwork(); + + result.identifier = payload.tokenIdentifier || payload.identifier || ""; + result.balance = new BigNumber(payload.balance || 0); + result.rawResponse = payload; + + return result; + } +} + +export class NonFungibleTokenOfAccountOnNetwork { + identifier: string = ""; + collection: string = ""; + timestamp: number = 0; + attributes: Buffer = Buffer.from([]); + nonce: number = 0; + type: string = ""; + name: string = ""; + creator: IAddress = new Address(""); + supply: BigNumber = new BigNumber(0); + decimals: number = 0; + royalties: BigNumber = new BigNumber(0); + assets: string[] = []; + balance: BigNumber = new BigNumber(0); + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromProxyHttpResponse(payload: any): NonFungibleTokenOfAccountOnNetwork { + let result = NonFungibleTokenOfAccountOnNetwork.fromHttpResponse(payload); + + result.identifier = payload.tokenIdentifier || ""; + result.collection = NonFungibleTokenOfAccountOnNetwork.parseCollectionFromIdentifier(result.identifier); + result.royalties = new BigNumber(payload.royalties || 0).div(100); + + return result; + } + + static fromProxyHttpResponseByNonce(payload: any): NonFungibleTokenOfAccountOnNetwork { + let result = NonFungibleTokenOfAccountOnNetwork.fromHttpResponse(payload); + let nonceAsHex = new Nonce(result.nonce).hex(); + + result.identifier = `${payload.tokenIdentifier}-${nonceAsHex}`; + result.collection = payload.tokenIdentifier || ""; + result.royalties = new BigNumber(payload.royalties || 0).div(100); + + return result; + } + + static fromApiHttpResponse(payload: any): NonFungibleTokenOfAccountOnNetwork { + let result = NonFungibleTokenOfAccountOnNetwork.fromHttpResponse(payload); + + result.identifier = payload.identifier || ""; + result.collection = payload.collection || ""; + + return result; + } + + // TODO: Compare results from Proxy and API and try to reconciliate them. + private static fromHttpResponse(payload: any): NonFungibleTokenOfAccountOnNetwork { + let result = new NonFungibleTokenOfAccountOnNetwork(); + + result.timestamp = Number(payload.timestamp || 0); + result.attributes = Buffer.from(payload.attributes || "", "base64"); + result.nonce = payload.nonce || 0; + result.type = payload.type || ""; + result.name = payload.name || ""; + result.creator = new Address(payload.creator || ""); + result.decimals = Number(payload.decimals || 0); + result.supply = new BigNumber(payload.balance || 1); + result.royalties = new BigNumber(payload.royalties || 0); + result.assets = payload.assets || []; + result.balance = new BigNumber(payload.balance || 1); + + return result; + } + + private static parseCollectionFromIdentifier(identifier: string): string { + let parts = identifier.split("-"); + let collection = parts.slice(0, 2).join("-"); + return collection; + } +} diff --git a/src-network-providers/transactionEvents.ts b/src-network-providers/transactionEvents.ts new file mode 100644 index 00000000..b37d47ab --- /dev/null +++ b/src-network-providers/transactionEvents.ts @@ -0,0 +1,90 @@ +import { IAddress } from "./interface"; +import { Address } from "./primitives"; + +export class TransactionEvent { + address: IAddress = new Address(""); + identifier: string = ""; + topics: TransactionEventTopic[] = []; + + /** + * @deprecated Use "dataPayload" instead. + */ + data: string = ""; + dataPayload: TransactionEventData = new TransactionEventData(Buffer.from("", "utf8")); + additionalData: TransactionEventData[] = []; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromHttpResponse(responsePart: { + address: string, + identifier: string, + topics: string[], + data: string, + additionalData?: string[] + }): TransactionEvent { + let result = new TransactionEvent(); + result.address = new Address(responsePart.address); + result.identifier = responsePart.identifier || ""; + result.topics = (responsePart.topics || []).map(topic => new TransactionEventTopic(topic)); + + result.dataPayload = TransactionEventData.fromBase64(responsePart.data); + result.additionalData = (responsePart.additionalData || []).map(TransactionEventData.fromBase64); + result.data = result.dataPayload.toString(); + + return result; + } + + findFirstOrNoneTopic(predicate: (topic: TransactionEventTopic) => boolean): TransactionEventTopic | undefined { + return this.topics.filter(topic => predicate(topic))[0]; + } + + getLastTopic(): TransactionEventTopic { + return this.topics[this.topics.length - 1]; + } +} + +export class TransactionEventData { + private readonly raw: Buffer; + + constructor(data: Buffer) { + this.raw = data; + } + + static fromBase64(str: string): TransactionEventData { + return new TransactionEventData(Buffer.from(str || "", "base64")); + } + + toString(): string { + return this.raw.toString("utf8"); + } + + hex(): string { + return this.raw.toString("hex"); + } + + valueOf(): Buffer { + return this.raw; + } +} + +export class TransactionEventTopic { + private readonly raw: Buffer; + + constructor(topic: string) { + this.raw = Buffer.from(topic || "", "base64"); + } + + toString(): string { + return this.raw.toString("utf8"); + } + + hex(): string { + return this.raw.toString("hex"); + } + + valueOf(): Buffer { + return this.raw; + } +} diff --git a/src-network-providers/transactionLogs.ts b/src-network-providers/transactionLogs.ts new file mode 100644 index 00000000..9e7a6ef1 --- /dev/null +++ b/src-network-providers/transactionLogs.ts @@ -0,0 +1,45 @@ +import { ErrUnexpectedCondition } from "./errors"; +import { IAddress } from "./interface"; +import { Address } from "./primitives"; +import { TransactionEvent } from "./transactionEvents"; + +export class TransactionLogs { + address: IAddress = new Address(""); + events: TransactionEvent[] = []; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromHttpResponse(logs: any): TransactionLogs { + let result = new TransactionLogs(); + result.address = new Address(logs.address); + result.events = (logs.events || []).map((event: any) => TransactionEvent.fromHttpResponse(event)); + + return result; + } + + findSingleOrNoneEvent(identifier: string, predicate?: (event: TransactionEvent) => boolean): TransactionEvent | undefined { + let events = this.findEvents(identifier, predicate); + + if (events.length > 1) { + throw new ErrUnexpectedCondition(`more than one event of type ${identifier}`); + } + + return events[0]; + } + + findFirstOrNoneEvent(identifier: string, predicate?: (event: TransactionEvent) => boolean): TransactionEvent | undefined { + return this.findEvents(identifier, predicate)[0]; + } + + findEvents(identifier: string, predicate?: (event: TransactionEvent) => boolean): TransactionEvent[] { + let events = this.events.filter(event => event.identifier == identifier); + + if (predicate) { + events = events.filter(event => predicate(event)); + } + + return events; + } +} diff --git a/src-network-providers/transactionReceipt.ts b/src-network-providers/transactionReceipt.ts new file mode 100644 index 00000000..07e72fb5 --- /dev/null +++ b/src-network-providers/transactionReceipt.ts @@ -0,0 +1,25 @@ +import { IAddress } from "./interface"; +import { Address } from "./primitives"; + +export class TransactionReceipt { + value: string = ""; + sender: IAddress = new Address(""); + data: string = ""; + hash: string = ""; + + static fromHttpResponse(response: { + value: string, + sender: string, + data: string, + txHash: string + }): TransactionReceipt { + let receipt = new TransactionReceipt(); + + receipt.value = (response.value || 0).toString(); + receipt.sender = new Address(response.sender); + receipt.data = response.data; + receipt.hash = response.txHash; + + return receipt; + } +} diff --git a/src-network-providers/transactionStatus.ts b/src-network-providers/transactionStatus.ts new file mode 100644 index 00000000..bb509cbc --- /dev/null +++ b/src-network-providers/transactionStatus.ts @@ -0,0 +1,86 @@ +/** + * An abstraction for handling and interpreting the "status" field of a transaction. + */ +export class TransactionStatus { + /** + * The raw status, as fetched from the Network. + */ + readonly status: string; + + /** + * Creates a new TransactionStatus object. + */ + constructor(status: string) { + this.status = (status || "").toLowerCase(); + } + + /** + * Creates an unknown status. + */ + static createUnknown(): TransactionStatus { + return new TransactionStatus("unknown"); + } + + /** + * Returns whether the transaction is pending (e.g. in mempool). + */ + isPending(): boolean { + return ( + this.status == "received" || + this.status == "pending" + ); + } + + /** + * Returns whether the transaction has been executed (not necessarily with success). + */ + isExecuted(): boolean { + return this.isSuccessful() || this.isFailed() || this.isInvalid(); + } + + /** + * Returns whether the transaction has been executed successfully. + */ + isSuccessful(): boolean { + return ( + this.status == "executed" || + this.status == "success" || + this.status == "successful" + ); + } + + /** + * Returns whether the transaction has been executed, but with a failure. + */ + isFailed(): boolean { + return ( + this.status == "fail" || + this.status == "failed" || + this.status == "unsuccessful" || + this.isInvalid() + ); + } + + /** + * Returns whether the transaction has been executed, but marked as invalid (e.g. due to "insufficient funds"). + */ + isInvalid(): boolean { + return this.status == "invalid"; + } + + toString(): string { + return this.status; + } + + valueOf(): string { + return this.status; + } + + equals(other: TransactionStatus) { + if (!other) { + return false; + } + + return this.status == other.status; + } +} diff --git a/src-network-providers/transactions.ts b/src-network-providers/transactions.ts new file mode 100644 index 00000000..beaecc0a --- /dev/null +++ b/src-network-providers/transactions.ts @@ -0,0 +1,112 @@ +import { TransactionStatus } from "./transactionStatus"; +import { ContractResults } from "./contractResults"; +import { Address } from "./primitives"; +import { IAddress, ITransaction, ITransactionNext } from "./interface"; +import { TransactionLogs } from "./transactionLogs"; +import { TransactionReceipt } from "./transactionReceipt"; + +export function prepareTransactionForBroadcasting(transaction: ITransaction | ITransactionNext): any { + if ("toSendable" in transaction){ + return transaction.toSendable(); + } + + return { + nonce: Number(transaction.nonce), + value: transaction.value.toString(), + receiver: transaction.receiver, + sender: transaction.sender, + senderUsername: transaction.senderUsername ? Buffer.from(transaction.senderUsername).toString("base64") : undefined, + receiverUsername: transaction.receiverUsername ? Buffer.from(transaction.receiverUsername).toString("base64") : undefined, + gasPrice: Number(transaction.gasPrice), + gasLimit: Number(transaction.gasLimit), + data: transaction.data.length === 0 ? undefined : Buffer.from(transaction.data).toString("base64"), + chainID: transaction.chainID, + version: transaction.version, + options: transaction.options, + guardian: transaction.guardian || undefined, + signature: Buffer.from(transaction.signature).toString("hex"), + guardianSignature: transaction.guardianSignature.length === 0 ? undefined : Buffer.from(transaction.guardianSignature).toString("hex"), + } +} + +export class TransactionOnNetwork { + isCompleted?: boolean; + hash: string = ""; + type: string = ""; + nonce: number = 0; + round: number = 0; + epoch: number = 0; + value: string = ""; + receiver: IAddress = new Address(""); + sender: IAddress = new Address(""); + gasLimit: number = 0; + gasPrice: number = 0; + function: string = ""; + data: Buffer = Buffer.from([]); + signature: string = ""; + status: TransactionStatus = TransactionStatus.createUnknown(); + timestamp: number = 0; + + blockNonce: number = 0; + hyperblockNonce: number = 0; + hyperblockHash: string = ""; + + receipt: TransactionReceipt = new TransactionReceipt(); + contractResults: ContractResults = new ContractResults([]); + logs: TransactionLogs = new TransactionLogs(); + + constructor(init?: Partial) { + Object.assign(this, init); + } + + static fromProxyHttpResponse(txHash: string, response: any, processStatus?: TransactionStatus | undefined): TransactionOnNetwork { + let result = TransactionOnNetwork.fromHttpResponse(txHash, response); + result.contractResults = ContractResults.fromProxyHttpResponse(response.smartContractResults || []); + + if (processStatus) { + result.status = processStatus; + result.isCompleted = result.status.isSuccessful() || result.status.isFailed() + } + + return result; + } + + static fromApiHttpResponse(txHash: string, response: any): TransactionOnNetwork { + let result = TransactionOnNetwork.fromHttpResponse(txHash, response); + result.contractResults = ContractResults.fromApiHttpResponse(response.results || []); + result.isCompleted = !result.status.isPending(); + return result; + } + + private static fromHttpResponse(txHash: string, response: any): TransactionOnNetwork { + let result = new TransactionOnNetwork(); + + result.hash = txHash; + result.type = response.type || ""; + result.nonce = response.nonce || 0; + result.round = response.round; + result.epoch = response.epoch || 0; + result.value = (response.value || 0).toString(); + result.sender = new Address(response.sender); + result.receiver = new Address(response.receiver); + result.gasPrice = response.gasPrice || 0; + result.gasLimit = response.gasLimit || 0; + result.function = response.function || ""; + result.data = Buffer.from(response.data || "", "base64"); + result.status = new TransactionStatus(response.status); + result.timestamp = response.timestamp || 0; + + result.blockNonce = response.blockNonce || 0; + result.hyperblockNonce = response.hyperblockNonce || 0; + result.hyperblockHash = response.hyperblockHash || ""; + + result.receipt = TransactionReceipt.fromHttpResponse(response.receipt || {}); + result.logs = TransactionLogs.fromHttpResponse(response.logs || {}); + + return result; + } + + getDateTime(): Date { + return new Date(this.timestamp * 1000); + } +} diff --git a/src-network-providers/userAgent.ts b/src-network-providers/userAgent.ts new file mode 100644 index 00000000..e102f114 --- /dev/null +++ b/src-network-providers/userAgent.ts @@ -0,0 +1,19 @@ +import { AxiosHeaders } from "axios"; +import { NetworkProviderConfig } from "./networkProviderConfig"; +import { UnknownClientName } from "./constants"; + +export function extendUserAgent(userAgentPrefix: string, config: NetworkProviderConfig) { + if (!config.headers) { + config.headers = new AxiosHeaders({}) + }; + if (!config.clientName) { + console.log("Can you please provide the client name of the application that uses the SDK? It will be used for metrics.") + } + const headers = AxiosHeaders.from(config.headers as AxiosHeaders).normalize(true); + const resolvedClientName = config.clientName || UnknownClientName; + + const currentUserAgent = headers.hasUserAgent() ? headers.getUserAgent() : ''; + const newUserAgent = currentUserAgent ? `${currentUserAgent} ${userAgentPrefix}/${resolvedClientName}` : `${userAgentPrefix}/${resolvedClientName}`; + + headers.setUserAgent(newUserAgent, true); +}