diff --git a/package-lock.json b/package-lock.json index 7732c76..78f447e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@itheum/sdk-mx-data-nft", - "version": "2.7.0", + "version": "2.7.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@itheum/sdk-mx-data-nft", - "version": "2.7.0", + "version": "2.7.0-beta.1", "license": "GPL-3.0-only", "dependencies": { "@multiversx/sdk-core": "12.18.0", @@ -7421,6 +7421,7 @@ "which", "write-file-atomic" ], + "dev": true, "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^7.2.1", diff --git a/package.json b/package.json index c1bdc4f..7b62c21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@itheum/sdk-mx-data-nft", - "version": "2.7.0", + "version": "2.7.0-beta.4", "description": "SDK for Itheum's Data NFT Technology on MultiversX Blockchain", "main": "out/index.js", "types": "out/index.d.js", diff --git a/src/abis/core-mx-life-bonding-sc.abi.json b/src/abis/core-mx-life-bonding-sc.abi.json index ca5e4c6..056fe21 100644 --- a/src/abis/core-mx-life-bonding-sc.abi.json +++ b/src/abis/core-mx-life-bonding-sc.abi.json @@ -80,11 +80,30 @@ { "name": "nonce", "type": "u64" + } + ], + "outputs": [] + }, + { + "name": "proof", + "mutability": "mutable", + "payableInTokens": [ + "*" + ], + "inputs": [], + "outputs": [] + }, + { + "name": "claimRefund", + "mutability": "mutable", + "inputs": [ + { + "name": "token_identifier", + "type": "TokenIdentifier" }, { - "name": "new_lock_period", - "type": "optional", - "multi_arg": true + "name": "nonce", + "type": "u64" } ], "outputs": [] @@ -167,21 +186,18 @@ ] }, { - "name": "getCompensations", + "name": "getCompensationBlacklist", "mutability": "readonly", "inputs": [ { - "name": "token_identifier", - "type": "TokenIdentifier" - }, - { - "name": "nonce", + "name": "compensation_id", "type": "u64" } ], "outputs": [ { - "type": "Compensation" + "type": "variadic
", + "multi_result": true } ] }, @@ -204,6 +220,60 @@ "name": "getCompensation", "mutability": "readonly", "inputs": [ + { + "name": "compensation_id", + "type": "u64" + } + ], + "outputs": [ + { + "type": "Compensation" + } + ] + }, + { + "name": "getCompensations", + "mutability": "readonly", + "inputs": [ + { + "name": "input", + "type": "variadic>", + "multi_arg": true + } + ], + "outputs": [ + { + "type": "List" + } + ] + }, + { + "name": "getPagedCompensations", + "mutability": "readonly", + "inputs": [ + { + "name": "start_index", + "type": "u64" + }, + { + "name": "end_index", + "type": "u64" + } + ], + "outputs": [ + { + "type": "List" + } + ] + }, + { + "name": "getAddressRefundForCompensation", + "mutability": "readonly", + "inputs": [ + { + "name": "address", + "type": "Address" + }, { "name": "token_identifier", "type": "TokenIdentifier" @@ -215,7 +285,7 @@ ], "outputs": [ { - "type": "Compensation" + "type": "Option>>" } ] }, @@ -276,6 +346,35 @@ } ] }, + { + "name": "getPagedBonds", + "mutability": "readonly", + "inputs": [ + { + "name": "start_index", + "type": "u64" + }, + { + "name": "end_index", + "type": "u64" + } + ], + "outputs": [ + { + "type": "List" + } + ] + }, + { + "name": "getBondsLen", + "mutability": "readonly", + "inputs": [], + "outputs": [ + { + "type": "u32" + } + ] + }, { "name": "getLockPeriodsBonds", "mutability": "readonly", @@ -286,6 +385,57 @@ } ] }, + { + "name": "setBlacklist", + "mutability": "mutable", + "inputs": [ + { + "name": "compensation_id", + "type": "u64" + }, + { + "name": "addresses", + "type": "variadic
", + "multi_arg": true + } + ], + "outputs": [] + }, + { + "name": "removeBlacklist", + "mutability": "mutable", + "inputs": [ + { + "name": "compensation_id", + "type": "u64" + }, + { + "name": "addresses", + "type": "variadic
", + "multi_arg": true + } + ], + "outputs": [] + }, + { + "name": "initiateRefund", + "mutability": "mutable", + "inputs": [ + { + "name": "token_identifier", + "type": "TokenIdentifier" + }, + { + "name": "nonce", + "type": "u64" + }, + { + "name": "timestamp", + "type": "u64" + } + ], + "outputs": [] + }, { "name": "sanction", "mutability": "mutable", @@ -349,6 +499,18 @@ ], "outputs": [] }, + { + "name": "removeAcceptedCallers", + "mutability": "mutable", + "inputs": [ + { + "name": "callers", + "type": "variadic
", + "multi_arg": true + } + ], + "outputs": [] + }, { "name": "setBondToken", "mutability": "mutable", @@ -372,6 +534,18 @@ ], "outputs": [] }, + { + "name": "removePeriodsBonds", + "mutability": "mutable", + "inputs": [ + { + "name": "lock_periods", + "type": "variadic", + "multi_arg": true + } + ], + "outputs": [] + }, { "name": "setMinimumPenalty", "mutability": "mutable", @@ -475,12 +649,20 @@ { "name": "bond_amount", "type": "BigUint" + }, + { + "name": "remaining_amount", + "type": "BigUint" } ] }, "Compensation": { "type": "struct", "fields": [ + { + "name": "compensation_id", + "type": "u64" + }, { "name": "token_identifier", "type": "TokenIdentifier" @@ -490,7 +672,32 @@ "type": "u64" }, { - "name": "total_compenstation_amount", + "name": "accumulated_amount", + "type": "BigUint" + }, + { + "name": "proof_amount", + "type": "BigUint" + }, + { + "name": "end_date", + "type": "u64" + } + ] + }, + "EsdtTokenPayment": { + "type": "struct", + "fields": [ + { + "name": "token_identifier", + "type": "TokenIdentifier" + }, + { + "name": "token_nonce", + "type": "u64" + }, + { + "name": "amount", "type": "BigUint" } ] @@ -512,6 +719,23 @@ } ] }, + "Refund": { + "type": "struct", + "fields": [ + { + "name": "address", + "type": "Address" + }, + { + "name": "proof_of_refund", + "type": "EsdtTokenPayment" + }, + { + "name": "compensation_id", + "type": "u64" + } + ] + }, "State": { "type": "enum", "variants": [ diff --git a/src/bond.ts b/src/bond.ts index cc79e4f..7881de3 100644 --- a/src/bond.ts +++ b/src/bond.ts @@ -1,64 +1,42 @@ import { - AbiRegistry, Address, AddressValue, + BigUIntValue, ContractCallPayloadBuilder, + ContractFunction, IAddress, ResultsParser, - SmartContract, TokenIdentifierValue, Transaction, U64Value } from '@multiversx/sdk-core/out'; -import { ApiNetworkProvider } from '@multiversx/sdk-network-providers/out'; -import { ErrContractQuery, ErrNetworkConfig } from './errors'; -import { - EnvironmentsEnum, - bondContractAddress, - networkConfiguration -} from './config'; +import { EnvironmentsEnum, bondContractAddress } from './config'; +import { ErrContractQuery } from './errors'; +import BigNumber from 'bignumber.js'; import bondContractAbi from './abis/core-mx-life-bonding-sc.abi.json'; -import { Bond, Compensation, PenaltyType, State } from './interfaces'; import { parseBond, parseCompensation, + parseRefund, parseTokenIdentifier } from './common/utils'; -import BigNumber from 'bignumber.js'; - -export class BondContract { - readonly contract: SmartContract; - readonly chainID: string; - readonly networkProvider: ApiNetworkProvider; - readonly env: string; +import { Contract } from './contract'; +import { Bond, Compensation, PenaltyType, State } from './interfaces'; +export class BondContract extends Contract { /** * Creates a new instance of the DataNftMarket which can be used to interact with the marketplace smart contract * @param env 'devnet' | 'mainnet' | 'testnet' * @param timeout Timeout for the network provider (DEFAULT = 10000ms) */ constructor(env: string, timeout: number = 10000) { - if (!(env in EnvironmentsEnum)) { - throw new ErrNetworkConfig( - `Invalid environment: ${env}, Expected: 'devnet' | 'mainnet' | 'testnet'` - ); - } - this.env = env; - const networkConfig = networkConfiguration[env as EnvironmentsEnum]; - this.chainID = networkConfig.chainID; - this.networkProvider = new ApiNetworkProvider( - networkConfig.networkProvider, - { - timeout: timeout - } + super( + env, + new Address(bondContractAddress[env as EnvironmentsEnum]), + bondContractAbi, + timeout ); - const contractAddress = bondContractAddress[env as EnvironmentsEnum]; - - this.contract = new SmartContract({ - address: new Address(contractAddress), - abi: AbiRegistry.create(bondContractAbi) - }); } /** @@ -128,6 +106,34 @@ export class BondContract { } } + /** + * Returns a list of addresses that are blacklisted from claiming compensations + * @param compensationId compensaton id to query + * @returns + */ + async viewCompensationBlacklist(compensationId: number): Promise { + const interaction = this.contract.methodsExplicit.getCompensationBlacklist([ + new U64Value(compensationId) + ]); + const query = interaction.buildQuery(); + const queryResponse = await this.networkProvider.queryContract(query); + const endpointDefinition = interaction.getEndpoint(); + const { firstValue, returnCode } = new ResultsParser().parseQueryResponse( + queryResponse, + endpointDefinition + ); + if (returnCode.isSuccess()) { + return firstValue + ?.valueOf() + .map((address: any) => new Address(address).bech32()); + } else { + throw new ErrContractQuery( + 'viewCompensationBlacklist', + returnCode.toString() + ); + } + } + /** * Returns the contract lock periods and bond amounts */ @@ -182,6 +188,95 @@ export class BondContract { } } + /** + * Returns a `Compensation` object for the given compensation id + * @param compensationId compensation id to query + */ + async viewCompensation(compensationId: number) { + throw new Error('Not implemented'); + } + + /** + * Returns a `Compensation` object array for the given tokens + * @param tokens tokens to query + */ + async viewCompensations( + tokens: { tokenIdentifier: string; nonce: number }[] + ) { + let combinedArray = []; + for (const token of tokens) { + combinedArray.push(new TokenIdentifierValue(token.tokenIdentifier)); + combinedArray.push(new U64Value(token.nonce)); + } + const interaction = + this.contract.methodsExplicit.getCompensations(combinedArray); + const query = interaction.buildQuery(); + const queryResponse = await this.networkProvider.queryContract(query); + const endpointDefinition = interaction.getEndpoint(); + const { firstValue, returnCode } = new ResultsParser().parseQueryResponse( + queryResponse, + endpointDefinition + ); + if (returnCode.isSuccess()) { + const returnValue = firstValue?.valueOf(); + const compensations: Compensation[] = returnValue.map( + (compensation: Compensation) => parseCompensation(compensation) + ); + return compensations; + } else { + throw new ErrContractQuery('viewCompensations', returnCode.toString()); + } + } + + /** + * Returns an Optional `Compensation` and `Refund` object for the given address and compensation id + * @param address address to query + * @param compensationId compensation id to query + */ + async viewAddressRefund(address: IAddress, compensationId: number) { + const interaction = this.contract.methodsExplicit.getAddressRefund([ + new AddressValue(address), + new U64Value(compensationId) + ]); + const query = interaction.buildQuery(); + const queryResponse = await this.networkProvider.queryContract(query); + const endpointDefinition = interaction.getEndpoint(); + const { firstValue, returnCode } = new ResultsParser().parseQueryResponse( + queryResponse, + endpointDefinition + ); + if (returnCode.isSuccess()) { + const returnValue = firstValue?.valueOf(); + if (returnValue) { + const [compensation, refund] = returnValue; + const parsedCompensation = parseCompensation(compensation); + const parsedRefund = refund ? parseRefund(refund) : null; + return { compensation: parsedCompensation, refund: parsedRefund }; + } else { + return null; + } + } else { + throw new ErrContractQuery('viewAddressRefund', returnCode.toString()); + } + } + + /** + * Returns a `Compensation` object array for the given indexes + * @param start_index index to start + * @param end_index index to end + */ + async viewPagedCompensations(startIndex: number, endIndex: number) { + throw new Error('Not implemented'); + } + + /** + * Returns a `Bond` object for the given bondId + * @param bondId bond id to query + */ + async viewBond(bondId: number) { + throw new Error('Not implemented'); + } + /** * Returns a `Bond` object array for the given address * @param address address to query @@ -227,6 +322,15 @@ export class BondContract { } } + /** + * Returns a `Bond` object array for the given indexes + * @param start_index index to start + * @param end_index index to end + */ + async viewPagedBonds(startIndex: number, endIndex: number) { + throw new Error('Not implemented'); + } + /** * Returns a `Bond` object array for the given bondIds. * @param bondIds Bond ids to query. @@ -301,33 +405,6 @@ export class BondContract { throw new ErrContractQuery('viewBonds', returnCode.toString()); } } - /** - * Returns a `Compensation` object for the given tokenIdentifier and nonce - * @param tokenIdentifier token identifier to query - * @param nonce nonce to query - */ - async viewCompensation( - tokenIdentifier: string, - nonce: number - ): Promise { - const interaction = this.contract.methodsExplicit.getCompensation([ - new TokenIdentifierValue(tokenIdentifier), - new U64Value(nonce) - ]); - const query = interaction.buildQuery(); - const queryResponse = await this.networkProvider.queryContract(query); - const endpointDefinition = interaction.getEndpoint(); - const { firstValue, returnCode } = new ResultsParser().parseQueryResponse( - queryResponse, - endpointDefinition - ); - if (returnCode.isSuccess()) { - const compensation = parseCompensation(firstValue?.valueOf()); - return compensation; - } else { - throw new ErrContractQuery('getCompensation', returnCode.toString()); - } - } /** * Builds a `setAdministrator` transaction @@ -454,6 +531,18 @@ export class BondContract { throw new Error('Not implemented'); } + setBlacklist(senderAddress: IAddress, addresses: IAddress[]) { + throw new Error('Not implemented'); + } + + removeBlacklist(senderAddress: IAddress, addresses: IAddress[]) { + throw new Error('Not implemented'); + } + + removeAcceptedCallers(senderAddress: IAddress, addresses: IAddress[]) { + throw new Error('Not implemented'); + } + setBondToken(senderAddress: IAddress, tokenIdentifier: string) { throw new Error('Not implemented'); } @@ -466,6 +555,10 @@ export class BondContract { throw new Error('Not implemented'); } + removePeriodsBonds(senderAddress: IAddress, periods: number[]) { + throw new Error('Not implemented'); + } + setMinimumPenalty(senderAddress: IAddress, penalty: number) { throw new Error('Not implemented'); } @@ -478,6 +571,163 @@ export class BondContract { throw new Error('Not implemented'); } + initiateRefund( + senderAddress: IAddress, + tokenIdentifier: string, + nonce: number, + timestamp: number + ) { + throw new Error('Not implemented'); + } + + /** + * Builds a `bond` transaction with ESDT transfer + * @param senderAddress the address of the sender + * @param originalCaller the address of the original caller + * @param tokenIdentifier the token identifier of the NFT/SFT + * @param nonce the token identifier nonce + * @param lockPeriod the lock period for the bond + * @param payment the payment for the bond (tokenIdentifier and amount) + */ + bondWithESDT( + senderAddress: IAddress, + originalCaller: IAddress, + tokenIdentifier: string, + nonce: number, + lockPeriod: number, + payment: { + tokenIdentifier: string; + amount: BigNumber.Value; + } + ): Transaction { + const bondTx = new Transaction({ + value: 0, + data: new ContractCallPayloadBuilder() + .setFunction(new ContractFunction('ESDTTransfer')) + .addArg(new TokenIdentifierValue(payment.tokenIdentifier)) + .addArg(new BigUIntValue(payment.amount)) + .setFunction('bond') + .addArg(new AddressValue(originalCaller)) + .addArg(new TokenIdentifierValue(tokenIdentifier)) + .addArg(new U64Value(nonce)) + .addArg(new U64Value(lockPeriod)) + .build(), + receiver: this.contract.getAddress(), + sender: senderAddress, + gasLimit: 40_000_000, + chainID: this.chainID + }); + return bondTx; + } + + /** + * Builds a `bond` transaction with NFT/SFT transfer + * @param senderAddress the address of the sender + * @param originalCaller the address of the original caller + * @param tokenIdentifier the token identifier of the NFT/SFT + * @param nonce the token identifier nonce + * @param lockPeriod the lock period for the bond + * @param payment the payment for the bond (tokenIdentifier, nonce and amount) + */ + bondWithNFT( + senderAddress: IAddress, + originalCaller: IAddress, + tokenIdentifier: string, + nonce: number, + lockPeriod: number, + payment: { + tokenIdentifier: string; + nonce: number; + amount: BigNumber.Value; + } + ): Transaction { + const bondTx = new Transaction({ + value: 0, + data: new ContractCallPayloadBuilder() + .setFunction(new ContractFunction('ESDTNFTTransfer')) + .addArg(new TokenIdentifierValue(payment.tokenIdentifier)) + .addArg(new U64Value(payment.nonce)) + .addArg(new BigUIntValue(payment.amount)) + .setFunction('bond') + .addArg(new AddressValue(originalCaller)) + .addArg(new TokenIdentifierValue(tokenIdentifier)) + .addArg(new U64Value(nonce)) + .addArg(new U64Value(lockPeriod)) + .build(), + receiver: this.contract.getAddress(), + sender: senderAddress, + gasLimit: 40_000_000, + chainID: this.chainID + }); + return bondTx; + } + + /** + * Builds a `bond` transaction with EGLD transfer + * @param senderAddress the address of the sender + * @param originalCaller the address of the original caller + * @param tokenIdentifier the token identifier of the NFT/SFT + * @param nonce the token identifier nonce + * @param lockPeriod the lock period for the bond + * @param payment the payment for the bond (tokenIdentifier, nonce and amount) + */ + bondWithEGLD( + senderAddress: IAddress, + originalCaller: IAddress, + tokenIdentifier: string, + nonce: number, + lockPeriod: number, + payment: BigNumber.Value + ): Transaction { + const bondTx = new Transaction({ + value: payment, + data: new ContractCallPayloadBuilder() + .setFunction('bond') + .addArg(new AddressValue(originalCaller)) + .addArg(new TokenIdentifierValue(tokenIdentifier)) + .addArg(new U64Value(nonce)) + .addArg(new U64Value(lockPeriod)) + .build(), + receiver: this.contract.getAddress(), + sender: senderAddress, + gasLimit: 40_000_000, + chainID: this.chainID + }); + return bondTx; + } + + /** + * Builds a `bond` transaction with no payment + * @param senderAddress the address of the sender + * @param originalCaller the address of the original caller + * @param tokenIdentifier the token identifier of the NFT/SFT + * @param nonce the token identifier nonce + * @param lockPeriod the lock period for the bond + */ + bondWithNoPayment( + senderAddress: IAddress, + originalCaller: IAddress, + tokenIdentifier: string, + nonce: number, + lockPeriod: number + ): Transaction { + const bondTx = new Transaction({ + value: 0, + data: new ContractCallPayloadBuilder() + .setFunction('bond') + .addArg(new AddressValue(originalCaller)) + .addArg(new TokenIdentifierValue(tokenIdentifier)) + .addArg(new U64Value(nonce)) + .addArg(new U64Value(lockPeriod)) + .build(), + receiver: this.contract.getAddress(), + sender: senderAddress, + gasLimit: 40_000_000, + chainID: this.chainID + }); + return bondTx; + } + /** * Builds a `withdraw` transaction * @param senderAddress address of the sender @@ -515,24 +765,13 @@ export class BondContract { renew( senderAddress: IAddress, tokenIdentifier: string, - nonce: number, - newLockPeriod?: number + nonce: number ): Transaction { - let data; - if (newLockPeriod) { - data = new ContractCallPayloadBuilder() - .setFunction('renew') - .addArg(new TokenIdentifierValue(tokenIdentifier)) - .addArg(new U64Value(nonce)) - .addArg(new U64Value(newLockPeriod)) - .build(); - } else { - data = new ContractCallPayloadBuilder() - .setFunction('renew') - .addArg(new TokenIdentifierValue(tokenIdentifier)) - .addArg(new U64Value(nonce)) - .build(); - } + const data = new ContractCallPayloadBuilder() + .setFunction('renew') + .addArg(new TokenIdentifierValue(tokenIdentifier)) + .addArg(new U64Value(nonce)) + .build(); const renewTx = new Transaction({ value: 0, @@ -544,4 +783,61 @@ export class BondContract { }); return renewTx; } + + /** + * Builds a `proof` transaction + * @param senderAddress the address of the sender + * @param payment the payment (NFT/SFT) to prove + * @returns + */ + proof( + senderAddress: IAddress, + payment: { tokenIdentifier: string; nonce: number; amount: BigNumber.Value } + ): Transaction { + const data = new ContractCallPayloadBuilder() + .setFunction(new ContractFunction('ESDTNFTTransfer')) + .addArg(new TokenIdentifierValue(payment.tokenIdentifier)) + .addArg(new U64Value(payment.nonce)) + .addArg(new BigUIntValue(payment.amount)) + .setFunction('proof') + .build(); + + const proofTx = new Transaction({ + value: 0, + data, + receiver: this.contract.getAddress(), + sender: senderAddress, + gasLimit: 40_000_000, + chainID: this.chainID + }); + return proofTx; + } + + /** + * Builds a `claimRefund` transaction + * @param senderAddress address of the sender + * @param tokenIdentifier token identifier of the proven ownership NFT/SFT + * @param nonce nonce of the proven ownership NFT/SFT + */ + claimRefund( + senderAddress: IAddress, + tokenIdentifier: string, + nonce: number + ): Transaction { + const data = new ContractCallPayloadBuilder() + .setFunction('claimRefund') + .addArg(new TokenIdentifierValue(tokenIdentifier)) + .addArg(new U64Value(nonce)) + .build(); + + const claimRefundTx = new Transaction({ + value: 0, + data, + receiver: this.contract.getAddress(), + sender: senderAddress, + gasLimit: 10_000_000, + chainID: this.chainID + }); + return claimRefundTx; + } } diff --git a/src/common/utils.ts b/src/common/utils.ts index 0e2995d..7f24965 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -6,7 +6,14 @@ import { ErrMissingTrait, ErrMissingValueForTrait } from '../errors'; -import { Bond, Compensation, NftEnumType, NftType, Offer } from '../interfaces'; +import { + Bond, + Compensation, + NftEnumType, + NftType, + Offer, + Refund +} from '../interfaces'; import { EnvironmentsEnum, dataMarshalUrlOverride } from '../config'; export function numberToPaddedHex(value: BigNumber.Value) { @@ -84,17 +91,33 @@ export function parseBond(value: any): Bond { tokenIdentifier: value.token_identifier.toString(), nonce: value.nonce.toNumber(), lockPeriod: value.lock_period.toNumber(), - bond_timestamp: value.bond_timestamp.toNumber(), - unbound_timestamp: value.unbound_timestamp.toNumber(), - bond_amount: value.bond_amount.toFixed(0) + bondTimestamp: value.bond_timestamp.toNumber(), + unboundTimestamp: value.unbound_timestamp.toNumber(), + bondAmount: value.bond_amount.toFixed(0), + remainingAmount: value.remaining_amount.toFixed(0) }; } export function parseCompensation(value: any): Compensation { return { + compensationId: value.compensation_id.toNumber(), tokenIdentifier: value.token_identifier.toString(), nonce: value.nonce.toNumber(), - totalCompensationAmount: value.total_compenstation_amount.toFixed(0) + accumulatedAmount: value.accumulate_amount.toFixed(0), + proofAmount: value.proof_amount.toFixed(0), + endDate: value.end_date.toNumber() + }; +} + +export function parseRefund(value: any): Refund { + return { + compensationId: value.compensation_id.toNumber(), + address: value.address.toString(), + proofOfRefund: { + tokenIdentifier: value.proof_of_refund.token_identifier.toString(), + nonce: value.proof_of_refund.token_nonce.toNumber(), + amount: value.proof_of_refund.amount.toFixed(0) + } }; } @@ -462,198 +485,6 @@ export function validateSpecificParamsViewData(params: { }; } -export function validateSpecificParamsMint(params: { - senderAddress?: any; - tokenName?: string | undefined; - datasetTitle?: string | undefined; - datasetDescription?: string | undefined; - royalties?: number | undefined; - supply?: number | undefined; - antiSpamTax?: BigNumber.Value | undefined; - _mandatoryParamsList: string[]; // a pure JS fallback way to validate mandatory params, as typescript rules for mandatory can be bypassed by client app -}): { - allPassed: boolean; - validationMessages: string; -} { - let allPassed = true; - let validationMessages = ''; - - try { - // senderAddress test - let senderAddressValid = true; - - if ( - params.senderAddress !== undefined || - params._mandatoryParamsList.includes('senderAddress') - ) { - senderAddressValid = false; - - if (params.senderAddress !== undefined) { - senderAddressValid = true; - } else { - validationMessages += '[senderAddress needs to be a valid type]'; - } - } - - // tokenName test - let tokenNameValid = true; - - if ( - params.tokenName !== undefined || - params._mandatoryParamsList.includes('tokenName') - ) { - tokenNameValid = false; // it exists or needs to exist, so we need to validate - - if ( - params.tokenName !== undefined && - typeof params.tokenName === 'string' && - params.tokenName.trim() !== '' && - params.tokenName.trim().match(/^[a-zA-Z0-9]+$/) && - params.tokenName.trim().length >= 3 && - params.tokenName.trim().length <= 20 - ) { - tokenNameValid = true; - } else { - validationMessages += - '[tokenName needs to be a string between 3 and 20 characters (Only alphanumeric characters allowed, no spaces allowed)]'; - } - } - - // datasetTitle test - let datasetTitleValid = true; - - if ( - params.datasetTitle !== undefined || - params._mandatoryParamsList.includes('datasetTitle') - ) { - datasetTitleValid = false; // it exists or needs to exist, so we need to validate - - if ( - params.datasetTitle !== undefined && - typeof params.datasetTitle === 'string' && - params.datasetTitle.trim() !== '' && - params.datasetTitle.trim().match(/^[a-zA-Z0-9\s]+$/) && - params.datasetTitle.trim().length >= 10 && - params.datasetTitle.trim().length <= 60 - ) { - datasetTitleValid = true; - } else { - validationMessages += - '[datasetTitle needs to be a string between 10 and 60 characters (Only alphanumeric characters)]'; - } - } - - // datasetDescription test - let datasetDescriptionValid = true; - - if ( - params.datasetDescription !== undefined || - params._mandatoryParamsList.includes('datasetDescription') - ) { - datasetDescriptionValid = false; // it exists or needs to exist, so we need to validate - - if ( - params.datasetDescription !== undefined && - typeof params.datasetDescription === 'string' && - params.datasetDescription.trim() !== '' && - params.datasetDescription.trim().match(/^[a-zA-Z0-9\s]+$/) && - params.datasetDescription.trim().length >= 10 && - params.datasetDescription.trim().length <= 400 - ) { - datasetDescriptionValid = true; - } else { - validationMessages += - '[datasetDescription needs to be a string between 10 and 400 characters (Only alphanumeric characters)]'; - } - } - - // royalties test - let royaltiesValid = true; - - if ( - params.royalties !== undefined || - params._mandatoryParamsList.includes('royalties') - ) { - royaltiesValid = false; - - if ( - params.royalties !== undefined && - typeof params.royalties === 'number' && - !(params.royalties % 1 != 0) && // modulus checking. (10 % 1 != 0) EQ false, (10.5 % 1 != 0) EQ true, - params.royalties >= 0 && - params.royalties <= 5000 - ) { - royaltiesValid = true; - } else { - validationMessages += - '[royalties needs to a whole number (not decimal) between 0 and 50]'; - } - } - - // supply test - let supplyValid = true; - - if ( - params.supply !== undefined || - params._mandatoryParamsList.includes('supply') - ) { - supplyValid = false; - - if ( - params.supply !== undefined && - typeof params.supply === 'number' && - params.supply >= 1 && - params.supply <= 1000 - ) { - supplyValid = true; - } else { - validationMessages += '[supply needs to a number between 1 and 1000]'; - } - } - - // antiSpamTax test - let antiSpamTaxValid = true; - - if ( - params.antiSpamTax !== undefined || - params._mandatoryParamsList.includes('antiSpamTax') - ) { - antiSpamTaxValid = false; - - if ( - params.antiSpamTax !== undefined && - typeof params.antiSpamTax === 'number' && - params.antiSpamTax >= 0 - ) { - antiSpamTaxValid = true; - } else { - validationMessages += - '[antiSpamTax needs to be a number greater than or equal to 0]'; - } - } - - if ( - !senderAddressValid || - !tokenNameValid || - !datasetTitleValid || - !datasetDescriptionValid || - !royaltiesValid || - !supplyValid || - !antiSpamTaxValid - ) { - allPassed = false; - } - } catch (e: any) { - allPassed = false; - validationMessages = e.toString(); - } - - return { - allPassed, - validationMessages - }; -} - export async function checkUrlIsUp(url: string, expectedHttpCodes: number[]) { // also do an https check as well if (!url.trim().toLowerCase().includes('https://')) { diff --git a/src/common/validator.ts b/src/common/validator.ts new file mode 100644 index 0000000..f02dac5 --- /dev/null +++ b/src/common/validator.ts @@ -0,0 +1,211 @@ +export type Result = { ok: true; value: T } | { ok: false; message: string }; + +interface IValidator { + validate(value: unknown): Result; +} +export function validateResults( + results: (Result | Result)[] +): void { + const errors: string[] = []; + + results.forEach((result, index) => { + if (!result.ok) { + errors.push(`Result at index ${index}: ${result.message}`); + } + }); + + if (errors.length > 0) { + throw new Error(`Validation Error: ${errors.join('\n')}`); + } +} + +type StringRule = + | { type: 'equal'; value: string } + | { type: 'notEqual'; value: string } + | { type: 'minLength'; min: number } + | { type: 'maxLength'; max: number } + | { type: 'alphanumeric' }; + +type NumericRule = + | { type: 'minValue'; value: number } + | { type: 'maxValue'; value: number } + | { type: 'integer' }; + +export class StringValidator implements IValidator { + private rules: StringRule[]; + + constructor(rules: StringRule[] = []) { + this.rules = rules; + } + + private addRule(rule: StringRule): void { + this.rules.push(rule); + } + + equals(value: string): StringValidator { + this.addRule({ type: 'equal', value }); + return this; + } + + notEquals(value: string): StringValidator { + this.addRule({ type: 'notEqual', value }); + return this; + } + + minLength(min: number): StringValidator { + this.addRule({ type: 'minLength', min }); + return this; + } + + maxLength(max: number): StringValidator { + this.addRule({ type: 'maxLength', max }); + return this; + } + + alphanumeric(): StringValidator { + this.addRule({ type: 'alphanumeric' }); + return this; + } + + notEmpty(): StringValidator { + this.addRule({ type: 'minLength', min: 1 }); + return this; + } + + validate(value: unknown): Result { + if (typeof value !== 'string') { + return { + ok: false, + message: `Validator expected a string but received ${typeof value}.` + }; + } + + let result: Result = { ok: true, value }; + + for (const rule of this.rules) { + result = this.checkStringRule(rule, value); + if (!result.ok) { + break; + } + } + + return result; + } + + private checkStringRule(rule: StringRule, value: string): Result { + switch (rule.type) { + case 'equal': + return rule.value !== value + ? { + ok: false, + message: `Value was expected to be '${rule.value}' but was '${value}'.` + } + : { ok: true, value }; + + case 'notEqual': + return rule.value === value + ? { ok: false, message: `Value must not be '${rule.value}'.` } + : { ok: true, value }; + + case 'minLength': + return value.length < rule.min + ? { + ok: false, + message: `String length must be greater than or equal to ${rule.min} but was ${value.length}.` + } + : { ok: true, value }; + + case 'maxLength': + return value.length > rule.max + ? { + ok: false, + message: `String length must be less than or equal to ${rule.max} but was ${value.length}.` + } + : { ok: true, value }; + + case 'alphanumeric': + return /^[a-zA-Z0-9]+$/.test(value) + ? { ok: true, value } + : { + ok: false, + message: 'Value must contain only alphanumeric characters.' + }; + + default: + return { ok: true, value }; + } + } +} + +export class NumericValidator implements IValidator { + private rules: NumericRule[]; + + constructor(rules: NumericRule[] = []) { + this.rules = rules; + } + + private addRule(rule: NumericRule): void { + this.rules.push(rule); + } + + minValue(value: number): NumericValidator { + this.addRule({ type: 'minValue', value }); + return this; + } + + maxValue(value: number): NumericValidator { + this.addRule({ type: 'maxValue', value }); + return this; + } + + integer(): NumericValidator { + this.addRule({ type: 'integer' }); + return this; + } + + validate(value: unknown): Result { + if (typeof value !== 'number' || isNaN(value)) { + return { + ok: false, + message: `Validator expected a number but received ${typeof value}.` + }; + } + + let result: Result = { ok: true, value }; + + for (const rule of this.rules) { + result = this.checkNumericRule(rule, value); + if (!result.ok) { + break; + } + } + + return result; + } + + private checkNumericRule(rule: NumericRule, value: number): Result { + switch (rule.type) { + case 'minValue': + return value < rule.value + ? { + ok: false, + message: `Value must be greater than or equal to ${rule.value}.` + } + : { ok: true, value }; + + case 'maxValue': + return value > rule.value + ? { + ok: false, + message: `Value must be less than or equal to ${rule.value}.` + } + : { ok: true, value }; + case 'integer': + return value % 1 !== 0 + ? { ok: false, message: 'Value must be an integer.' } + : { ok: true, value }; + default: + return { ok: true, value }; + } + } +} diff --git a/src/contract.ts b/src/contract.ts new file mode 100644 index 0000000..a6c77f4 --- /dev/null +++ b/src/contract.ts @@ -0,0 +1,46 @@ +import { + AbiRegistry, + ErrContract, + IAddress, + SmartContract +} from '@multiversx/sdk-core/out'; +import { ApiNetworkProvider } from '@multiversx/sdk-network-providers/out'; +import { EnvironmentsEnum, networkConfiguration } from './config'; +import { ErrContractAddressNotSet, ErrNetworkConfig } from './errors'; + +export abstract class Contract { + readonly contract: SmartContract; + readonly chainID: string; + readonly networkProvider: ApiNetworkProvider; + readonly env: string; + + protected constructor( + env: string, + contractAddress: IAddress, + abiFile: any, + timeout: number = 10000 + ) { + if (!(env in EnvironmentsEnum)) { + throw new ErrNetworkConfig( + `Invalid environment: ${env}, Expected: 'devnet' | 'mainnet' | 'testnet'` + ); + } + if (!contractAddress.bech32()) { + throw new ErrContractAddressNotSet(env); + } + + this.env = env; + const networkConfig = networkConfiguration[env as EnvironmentsEnum]; + this.chainID = networkConfig.chainID; + this.networkProvider = new ApiNetworkProvider( + networkConfig.networkProvider, + { + timeout: timeout + } + ); + this.contract = new SmartContract({ + address: contractAddress, + abi: AbiRegistry.create(abiFile) + }); + } +} diff --git a/src/errors.ts b/src/errors.ts index b3c65e0..8733103 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -9,6 +9,12 @@ export class ErrNetworkConfig extends Error { } } +export class ErrContractAddressNotSet extends Error { + public constructor(env: string, message?: string) { + super(message || 'Contract address is not deployed on ' + env); + } +} + export class ErrArgumentNotSet extends Error { public constructor(argument: string, message?: string) { super(`Argument "${argument}" is not set. ${message}`); diff --git a/src/index.ts b/src/index.ts index dd05012..a703446 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,5 @@ export * from './minter'; export * from './nft-minter'; export * from './sft-minter'; export * from './bond'; +export * from './contract'; export { parseTokenIdentifier, createTokenIdentifier } from './common/utils'; diff --git a/src/interfaces.ts b/src/interfaces.ts index 2aa792c..91a9e9b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -130,10 +130,30 @@ export interface Bond { address: string; tokenIdentifier: string; nonce: number; - lockPeriod: number; // days - bond_timestamp: number; - unbound_timestamp: number; - bond_amount: BigNumber.Value; + lockPeriod: number; // seconds + bondTimestamp: number; + unboundTimestamp: number; + bondAmount: BigNumber.Value; + remainingAmount: BigNumber.Value; +} + +export interface Refund { + compensationId: number; + address: string; + proofOfRefund: { + tokenIdentifier: string; + nonce: number; + amount: BigNumber.Value; + }; +} + +export interface Compensation { + compensationId: number; + tokenIdentifier: string; + nonce: number; + accumulatedAmount: BigNumber.Value; + proofAmount: BigNumber.Value; + endDate: number; } export enum State { @@ -147,12 +167,6 @@ export enum PenaltyType { Maximum = 2 } -export interface Compensation { - tokenIdentifier: string; - nonce: number; - totalCompensationAmount: string; -} - export interface ViewDataReturnType { data: any; contentType: string; diff --git a/src/marketplace.ts b/src/marketplace.ts index d60e45d..4bb96a0 100644 --- a/src/marketplace.ts +++ b/src/marketplace.ts @@ -210,7 +210,7 @@ export class DataNftMarket { new U64Value(to) ]); if (senderAddress) { - interaction = this.contract.methodsExplicit.viewPagedOffersByAddress([ + interaction = this.contract.methodsExplicit.viewPagedOffers([ new U64Value(from), new U64Value(to), new AddressValue(senderAddress) @@ -561,11 +561,13 @@ export class DataNftMarket { * Creates a `cancelOffer` transaction * @param senderAddress the address of the sender * @param offerId the id of the offer to be cancelled + * @param quantity the quantity of the offer to be cancelled * @param sendFundsBackToOwner default `true`, if `false` the offer will be cancelled, but the funds will be kept in the contract until withdrawal */ cancelOffer( senderAddress: IAddress, offerId: number, + quantity: number, sendFundsBackToOwner = true ): Transaction { const cancelTx = new Transaction({ @@ -573,6 +575,7 @@ export class DataNftMarket { data: new ContractCallPayloadBuilder() .setFunction(new ContractFunction('cancelOffer')) .addArg(new U64Value(offerId)) + .addArg(new U64Value(quantity)) .addArg(new BooleanValue(sendFundsBackToOwner)) .build(), receiver: this.contract.getAddress(), diff --git a/src/minter.ts b/src/minter.ts index 2d54e8e..f3757bf 100644 --- a/src/minter.ts +++ b/src/minter.ts @@ -23,12 +23,9 @@ import { } from './config'; import { ErrContractQuery, ErrNetworkConfig } from './errors'; import BigNumber from 'bignumber.js'; +import { Contract } from './contract'; -export abstract class Minter { - readonly contract: SmartContract; - readonly chainID: string; - readonly networkProvider: ApiNetworkProvider; - readonly env: string; +export abstract class Minter extends Contract { readonly imageServiceUrl: string; protected constructor( @@ -37,25 +34,8 @@ export abstract class Minter { abiFile: any, timeout: number = 10000 ) { - if (!(env in EnvironmentsEnum)) { - throw new ErrNetworkConfig( - `Invalid environment: ${env}, Expected: 'devnet' | 'mainnet' | 'testnet'` - ); - } - this.env = env; - const networkConfig = networkConfiguration[env as EnvironmentsEnum]; + super(env, contractAddress, abiFile, timeout); this.imageServiceUrl = imageService[env as EnvironmentsEnum]; - this.chainID = networkConfig.chainID; - this.networkProvider = new ApiNetworkProvider( - networkConfig.networkProvider, - { - timeout: timeout - } - ); - this.contract = new SmartContract({ - address: contractAddress, - abi: AbiRegistry.create(abiFile) - }); } /** diff --git a/src/nft-minter.ts b/src/nft-minter.ts index 87ae6b5..bd502a1 100644 --- a/src/nft-minter.ts +++ b/src/nft-minter.ts @@ -17,11 +17,7 @@ import { dataNFTDataStreamAdvertise, storeToIpfs } from './common/mint-utils'; -import { - checkTraitsUrl, - checkUrlIsUp, - validateSpecificParamsMint -} from './common/utils'; +import { checkTraitsUrl, checkUrlIsUp } from './common/utils'; import { EnvironmentsEnum, itheumTokenIdentifier } from './config'; import { ErrArgumentNotSet, ErrContractQuery } from './errors'; import { ContractConfiguration, NftMinterRequirements } from './interfaces'; @@ -189,27 +185,6 @@ export class NftMinter extends Minter { antiSpamTax } = options ?? {}; - // S: run any format specific validation - const { allPassed, validationMessages } = validateSpecificParamsMint({ - senderAddress, - tokenName, - royalties, - datasetTitle, - datasetDescription, - _mandatoryParamsList: [ - 'senderAddress', - 'tokenName', - 'royalties', - 'datasetTitle', - 'datasetDescription' - ] - }); - - if (!allPassed) { - throw new Error(`Params have validation issues = ${validationMessages}`); - } - // E: run any format specific validation... - // deep validate all mandatory URLs try { await checkUrlIsUp(dataStreamUrl, [200, 403]); diff --git a/src/sft-minter.ts b/src/sft-minter.ts index 1453b87..cc9641b 100644 --- a/src/sft-minter.ts +++ b/src/sft-minter.ts @@ -17,11 +17,7 @@ import { dataNFTDataStreamAdvertise, storeToIpfs } from './common/mint-utils'; -import { - checkTraitsUrl, - checkUrlIsUp, - validateSpecificParamsMint -} from './common/utils'; +import { checkTraitsUrl, checkUrlIsUp } from './common/utils'; import { EnvironmentsEnum, itheumTokenIdentifier, @@ -31,6 +27,11 @@ import { ErrArgumentNotSet, ErrContractQuery } from './errors'; import { SftMinterRequirements } from './interfaces'; import { Minter } from './minter'; import BigNumber from 'bignumber.js'; +import { + NumericValidator, + StringValidator, + validateResults +} from './common/validator'; export class SftMinter extends Minter { /** @@ -252,28 +253,42 @@ export class SftMinter extends Minter { ): Promise { const { imageUrl, traitsUrl, nftStorageToken } = options ?? {}; - // S: run any format specific validation - const { allPassed, validationMessages } = validateSpecificParamsMint({ - senderAddress, - tokenName, - royalties, - supply, - datasetTitle, - datasetDescription, - _mandatoryParamsList: [ - 'senderAddress', - 'tokenName', - 'royalties', - 'supply', - 'datasetTitle', - 'datasetDescription' - ] - }); + const tokenNameValidator = new StringValidator() + .notEmpty() + .alphanumeric() + .minLength(3) + .maxLength(20) + .validate(tokenName); - if (!allPassed) { - throw new Error(`Params have validation issues = ${validationMessages}`); - } - // E: run any format specific validation... + const datasetTitleValidator = new StringValidator() + .notEmpty() + .minLength(10) + .maxLength(60) + .validate(datasetTitle.trim()); + + const datasetDescriptionValidator = new StringValidator() + .notEmpty() + .minLength(10) + .maxLength(400) + .validate(datasetDescription); + + const royaltiesValidator = new NumericValidator() + .integer() + .minValue(0) + .validate(royalties); + + const supplyValidator = new NumericValidator() + .integer() + .minValue(1) + .validate(supply); + + validateResults([ + tokenNameValidator, + datasetTitleValidator, + datasetDescriptionValidator, + royaltiesValidator, + supplyValidator + ]); // deep validate all mandatory URLs try { diff --git a/tests/bond.test.ts b/tests/bond.test.ts index 14b579a..8e8dd99 100644 --- a/tests/bond.test.ts +++ b/tests/bond.test.ts @@ -1,7 +1,15 @@ import { BondContract, Compensation, State } from '../src'; import { Bond } from '../src'; +import { ErrContractAddressNotSet } from '../src/errors'; describe('Bond test', () => { + test('#test no deploy', () => { + try { + const bondContract = new BondContract('testnet'); + } catch (e: any) { + expect(e.message).toBe('Contract address is not deployed on testnet'); + } + }); test('#test view methods', async () => { const bondContract = new BondContract('devnet'); diff --git a/tests/marketplace.test.ts b/tests/marketplace.test.ts index 8d3561b..3936b0a 100644 --- a/tests/marketplace.test.ts +++ b/tests/marketplace.test.ts @@ -117,6 +117,7 @@ describe('Marketplace Sdk test', () => { new Address( 'erd1qqqqqqqqqqqqqpgq7ykazrzd905zvnlr88dpfw06677lxe9w0n4suz00uh' ), + 0, 0 ); @@ -125,6 +126,7 @@ describe('Marketplace Sdk test', () => { 'erd1qqqqqqqqqqqqqpgq7ykazrzd905zvnlr88dpfw06677lxe9w0n4suz00uh' ), 0, + 0, false ); diff --git a/tests/validator.test.ts b/tests/validator.test.ts new file mode 100644 index 0000000..2858bc3 --- /dev/null +++ b/tests/validator.test.ts @@ -0,0 +1,87 @@ +import { + NumericValidator, + StringValidator, + validateResults +} from '../src/common/validator'; + +describe('test validator', () => { + test('#test validator', () => { + let value = new StringValidator().notEmpty().validate(''); + expect(value.ok).toBe(false); + + value = new StringValidator().notEmpty().validate('test'); + expect(value.ok).toBe(true); + + value = new StringValidator().alphanumeric().validate('tes333t'); + expect(value.ok).toBe(true); + + value = new StringValidator().alphanumeric().validate('tes333t@'); + expect(value.ok).toBe(false); + + let numberValue = new NumericValidator().validate(333); + expect(numberValue.ok).toBe(true); + + numberValue = new NumericValidator().minValue(10).validate(9); + expect(numberValue.ok).toBe(false); + + numberValue = new NumericValidator().minValue(10).validate('11'); + expect(numberValue.ok).toBe(false); + + numberValue = new NumericValidator().minValue(10).validate(11); + expect(numberValue.ok).toBe(true); + + numberValue = new NumericValidator().maxValue(10).validate('11'); + expect(numberValue.ok).toBe(false); + + numberValue = new NumericValidator().maxValue(10).validate(11); + expect(numberValue.ok).toBe(false); + + numberValue = new NumericValidator().maxValue(10).validate('9'); + expect(numberValue.ok).toBe(false); + + numberValue = new NumericValidator().maxValue(10).validate(9); + expect(numberValue.ok).toBe(true); + + numberValue = new NumericValidator().integer().validate(9.5); + expect(numberValue.ok).toBe(false); + + numberValue = new NumericValidator().integer().validate(900); + expect(numberValue.ok).toBe(true); + + value = new StringValidator().maxLength(10).validate('123456789011'); + expect(value.ok).toBe(false); + + value = new StringValidator().maxLength(10).validate('123456789'); + expect(value.ok).toBe(true); + + value = new StringValidator().minLength(10).validate('123456789'); + expect(value.ok).toBe(false); + + value = new StringValidator().minLength(10).validate('123456789011'); + expect(value.ok).toBe(true); + + value = new StringValidator().equals('test').validate('test'); + expect(value.ok).toBe(true); + + value = new StringValidator().equals('test').validate('test2'); + expect(value.ok).toBe(false); + + value = new StringValidator().notEquals('test').validate('test'); + expect(value.ok).toBe(false); + + value = new StringValidator().notEquals('test').validate('test2'); + }); + + test('#validateResults', () => { + const error1 = new StringValidator().notEmpty().validate(''); + const error2 = new StringValidator().alphanumeric().validate('abc33$$'); + + try { + validateResults([error1, error2]); + } catch (e: any) { + expect(e.message).toBe( + `Validation Error: Result at index 0: String length must be greater than or equal to 1 but was 0.\nResult at index 1: Value must contain only alphanumeric characters.` + ); + } + }); +});