diff --git a/src/constants.ts b/src/constants.ts index 66d72198a..11df29f34 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,3 +10,6 @@ export const ESDTNFT_TRANSFER_FUNCTION_NAME = "ESDTNFTTransfer"; export const MULTI_ESDTNFT_TRANSFER_FUNCTION_NAME = "MultiESDTNFTTransfer"; export const ESDT_TRANSFER_VALUE = "0"; export const ARGUMENTS_SEPARATOR = "@"; +export const VM_TYPE_WASM_VM = new Uint8Array([0x05, 0x00]); +export const CONTRACT_DEPLOY_ADDRESS = "erd1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6gq4hu" + diff --git a/src/testdata/adder.abi.json b/src/testdata/adder.abi.json new file mode 100644 index 000000000..88d5bf134 --- /dev/null +++ b/src/testdata/adder.abi.json @@ -0,0 +1,62 @@ +{ + "buildInfo": { + "rustc": { + "version": "1.71.0-nightly", + "commitHash": "7f94b314cead7059a71a265a8b64905ef2511796", + "commitDate": "2023-04-23", + "channel": "Nightly", + "short": "rustc 1.71.0-nightly (7f94b314c 2023-04-23)" + }, + "contractCrate": { + "name": "adder", + "version": "0.0.0" + }, + "framework": { + "name": "multiversx-sc", + "version": "0.41.3" + } + }, + "docs": [ + "One of the simplest smart contracts possible,", + "it holds a single variable in storage, which anyone can increment." + ], + "name": "Adder", + "constructor": { + "inputs": [ + { + "name": "initial_value", + "type": "BigUint" + } + ], + "outputs": [] + }, + "endpoints": [ + { + "name": "getSum", + "mutability": "readonly", + "inputs": [], + "outputs": [ + { + "type": "BigUint" + } + ] + }, + { + "docs": [ + "Add desired amount to the storage variable." + ], + "name": "add", + "mutability": "mutable", + "inputs": [ + { + "name": "value", + "type": "BigUint" + } + ], + "outputs": [] + } + ], + "events": [], + "hasCallback": false, + "types": {} +} diff --git a/src/testdata/adder.wasm b/src/testdata/adder.wasm new file mode 100755 index 000000000..77ce7e234 Binary files /dev/null and b/src/testdata/adder.wasm differ diff --git a/src/tokenOperations/codec.ts b/src/tokenOperations/codec.ts index 3b33abfd6..8b9bc5988 100644 --- a/src/tokenOperations/codec.ts +++ b/src/tokenOperations/codec.ts @@ -32,10 +32,7 @@ export function bigIntToHex(value: BigNumber.Value): string { return contractsCodecUtils.getHexMagnitudeOfBigInt(value); } -export function utf8ToHex(value: string) { - const hex = Buffer.from(value).toString("hex"); - return codecUtils.zeroPadStringIfOddLength(hex); -} +export { utf8ToHex } from "../utils.codec"; export function bufferToHex(value: Buffer) { const hex = value.toString("hex"); diff --git a/src/transactionIntent.ts b/src/transactionIntent.ts new file mode 100644 index 000000000..43cb9e36c --- /dev/null +++ b/src/transactionIntent.ts @@ -0,0 +1,23 @@ +import { BigNumber } from "bignumber.js"; + +export class TransactionIntent { + public sender: string; + public receiver: string; + public gasLimit: BigNumber.Value; + public value?: BigNumber.Value; + public data?: Uint8Array; + + public constructor(options: { + sender: string, + receiver: string, + gasLimit: BigNumber.Value, + value?: BigNumber.Value, + data?: Uint8Array + }) { + this.sender = options.sender; + this.receiver = options.receiver; + this.gasLimit = options.gasLimit; + this.value = options.value; + this.data = options.data; + } +} diff --git a/src/transactionIntentsFactories/smartContractTransactionIntentsFactory.spec.ts b/src/transactionIntentsFactories/smartContractTransactionIntentsFactory.spec.ts new file mode 100644 index 000000000..75d65332f --- /dev/null +++ b/src/transactionIntentsFactories/smartContractTransactionIntentsFactory.spec.ts @@ -0,0 +1,162 @@ +import { assert, expect } from "chai"; +import { SmartContractTransactionIntentsFactory } from "./smartContractTransactionIntentsFactory"; +import { Address } from "../address"; +import { Code } from "../smartcontracts/code"; +import { AbiRegistry } from "../smartcontracts/typesystem/abiRegistry"; +import { U32Value } from "../smartcontracts"; +import { CONTRACT_DEPLOY_ADDRESS } from "../constants"; +import { loadContractCode, loadAbiRegistry } from "../testutils/utils"; +import { Err } from "../errors"; + +describe("test smart contract intents factory", function () { + let factory: SmartContractTransactionIntentsFactory; + let abiAwareFactory: SmartContractTransactionIntentsFactory; + let adderByteCode: Code; + let abiRegistry: AbiRegistry; + + before(async function () { + factory = new SmartContractTransactionIntentsFactory({ + config: + { + chainID: "D", + minGasLimit: 50000, + gasLimitPerByte: 1500 + } + }); + + adderByteCode = await loadContractCode("src/testdata/adder.wasm"); + abiRegistry = await loadAbiRegistry("src/testdata/adder.abi.json"); + + abiAwareFactory = new SmartContractTransactionIntentsFactory({ + config: + { + chainID: "D", + minGasLimit: 50000, + gasLimitPerByte: 1500 + }, + abi: abiRegistry + }, + ); + }); + + it("should throw error when args are not of type 'TypedValue'", async function () { + const sender = Address.fromBech32("erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + const gasLimit = 6000000; + const args = [0]; + + assert.throws(() => factory.createTransactionIntentForDeploy({ + sender: sender, + bytecode: adderByteCode.valueOf(), + gasLimit: gasLimit, + args: args + }), Err, "Can't convert args to TypedValues"); + }); + + it("should build intent for deploy", async function () { + const sender = Address.fromBech32("erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + const gasLimit = 6000000; + const args = [new U32Value(0)]; + + const deployIntent = factory.createTransactionIntentForDeploy({ + sender: sender, + bytecode: adderByteCode.valueOf(), + gasLimit: gasLimit, + args: args + }); + const abiDeployIntent = abiAwareFactory.createTransactionIntentForDeploy({ + sender: sender, + bytecode: adderByteCode.valueOf(), + gasLimit: gasLimit, + args: args + }); + + assert.equal(deployIntent.sender, "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(deployIntent.receiver, CONTRACT_DEPLOY_ADDRESS); + assert.isDefined(deployIntent.data); + expect(deployIntent.data!.length).to.be.greaterThan(0); + + const expectedGasLimit = 6000000 + 50000 + 1500 * deployIntent.data!.length; + assert.equal(deployIntent.gasLimit.valueOf(), expectedGasLimit); + assert.equal(deployIntent.value, 0); + + assert.deepEqual(deployIntent, abiDeployIntent); + }); + + it("should build intent for execute", async function () { + const sender = Address.fromBech32("erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + const contract = Address.fromBech32("erd1qqqqqqqqqqqqqpgqhy6nl6zq07rnzry8uyh6rtyq0uzgtk3e69fqgtz9l4"); + const func = "add"; + const gasLimit = 6000000; + const args = [new U32Value(7)]; + + const deployIntent = factory.createTransactionIntentForExecute({ + sender: sender, + contractAddress: contract, + func: func, + gasLimit: gasLimit, + args: args + }); + const abiDeployIntent = abiAwareFactory.createTransactionIntentForExecute({ + sender: sender, + contractAddress: contract, + func: func, + gasLimit: gasLimit, + args: args + }); + + assert.equal(deployIntent.sender, "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(deployIntent.receiver, "erd1qqqqqqqqqqqqqpgqhy6nl6zq07rnzry8uyh6rtyq0uzgtk3e69fqgtz9l4"); + + assert.isDefined(deployIntent.data); + assert.deepEqual(deployIntent.data, Buffer.from("add@07")); + + assert.equal(deployIntent.gasLimit.valueOf(), 6059000); + assert.equal(deployIntent.value, 0); + + assert.deepEqual(deployIntent, abiDeployIntent); + }); + + it("should build intent for upgrade", async function () { + const sender = Address.fromBech32("erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + const contract = Address.fromBech32("erd1qqqqqqqqqqqqqpgqhy6nl6zq07rnzry8uyh6rtyq0uzgtk3e69fqgtz9l4"); + const gasLimit = 6000000; + const args = [new U32Value(0)]; + + const deployIntent = factory.createTransactionIntentForUpgrade({ + sender: sender, + contract: contract, + bytecode: adderByteCode.valueOf(), + gasLimit: gasLimit, + args: args + }); + const abiDeployIntent = abiAwareFactory.createTransactionIntentForUpgrade({ + sender: sender, + contract: contract, + bytecode: adderByteCode.valueOf(), + gasLimit: gasLimit, + args: args + }); + + assert.equal(deployIntent.sender, "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th"); + assert.equal(deployIntent.receiver, "erd1qqqqqqqqqqqqqpgqhy6nl6zq07rnzry8uyh6rtyq0uzgtk3e69fqgtz9l4"); + assert.isDefined(deployIntent.data); + assert(checkIfByteArrayStartsWith(deployIntent.data!, "upgradeContract@")); + + const expectedGasLimit = 6000000 + 50000 + 1500 * deployIntent.data!.length; + assert.equal(deployIntent.gasLimit.valueOf(), expectedGasLimit); + assert.equal(deployIntent.value, 0); + + assert.deepEqual(deployIntent, abiDeployIntent); + }); + + function checkIfByteArrayStartsWith(array: Uint8Array, sequence: string) { + const sequenceBytes = Buffer.from(sequence); + + for (let i = 0; i < sequenceBytes.length; i++) { + if (sequenceBytes[i] !== array[i]) { + return false; + } + } + return true; + } +}); diff --git a/src/transactionIntentsFactories/smartContractTransactionIntentsFactory.ts b/src/transactionIntentsFactories/smartContractTransactionIntentsFactory.ts new file mode 100644 index 000000000..21b5a83c7 --- /dev/null +++ b/src/transactionIntentsFactories/smartContractTransactionIntentsFactory.ts @@ -0,0 +1,151 @@ +import { BigNumber } from "bignumber.js"; +import { IAddress } from "../interface"; +import { TransactionIntent } from "../transactionIntent"; +import { AbiRegistry, ArgSerializer, CodeMetadata, EndpointDefinition } from "../smartcontracts"; +import { byteArrayToHex } from "../utils.codec"; +import { CONTRACT_DEPLOY_ADDRESS, VM_TYPE_WASM_VM } from "../constants"; +import { NativeSerializer } from "../smartcontracts/nativeSerializer"; +import { Err } from "../errors"; +import { Address } from "../address"; +import { TransactionIntentBuilder } from "./transactionIntentBuilder"; + +interface Config { + chainID: string; + minGasLimit: BigNumber.Value; + gasLimitPerByte: BigNumber.Value; +} + +export class SmartContractTransactionIntentsFactory { + private readonly config: Config; + private readonly abiRegistry?: AbiRegistry; + + constructor({ + config, + abi + }: { + config: Config; + abi?: AbiRegistry; + }) { + this.config = config; + this.abiRegistry = abi; + } + + createTransactionIntentForDeploy(options: { + sender: IAddress, + bytecode: Uint8Array, + gasLimit: BigNumber.Value, + args?: any[], + isUpgradeable?: boolean, + isReadable?: boolean, + isPayable?: boolean, + isPayableBySmartContract?: boolean + }): TransactionIntent { + const isUpgradeable = options.isUpgradeable || true; + const isReadable = options.isReadable || true; + const isPayable = options.isPayable || false; + const isPayableBySmartContract = options.isPayableBySmartContract || true; + + const args = options.args || []; + + const metadata = new CodeMetadata(isUpgradeable, isReadable, isPayable, isPayableBySmartContract); + let parts = [ + byteArrayToHex(options.bytecode), + byteArrayToHex(VM_TYPE_WASM_VM), + metadata.toString() + ]; + + const preparedArgs = this.argsToDataParts(args, this.abiRegistry?.constructorDefinition) + parts = parts.concat(preparedArgs); + + return new TransactionIntentBuilder({ + config: this.config, + sender: options.sender, + receiver: Address.fromBech32(CONTRACT_DEPLOY_ADDRESS), + dataParts: parts, + executionGasLimit: options.gasLimit + }).build(); + } + + createTransactionIntentForExecute(options: { + sender: IAddress, + contractAddress: IAddress, + func: string, + gasLimit: BigNumber.Value, + args?: any[] + } + ): TransactionIntent { + const args = options.args || []; + let parts: string[] = [options.func]; + + const preparedArgs = this.argsToDataParts(args, this.abiRegistry?.constructorDefinition) + parts = parts.concat(preparedArgs); + + return new TransactionIntentBuilder({ + config: this.config, + sender: options.sender, + receiver: options.contractAddress, + dataParts: parts, + executionGasLimit: options.gasLimit + }).build(); + } + + createTransactionIntentForUpgrade(options: { + sender: IAddress, + contract: IAddress, + bytecode: Uint8Array, + gasLimit: BigNumber.Value, + args?: any[], + isUpgradeable?: boolean, + isReadable?: boolean, + isPayable?: boolean, + isPayableBySmartContract?: boolean + } + ): TransactionIntent { + const isUpgradeable = options.isUpgradeable || true; + const isReadable = options.isReadable || true; + const isPayable = options.isPayable || false; + const isPayableBySmartContract = options.isPayableBySmartContract || true; + + const args = options.args || []; + const metadata = new CodeMetadata(isUpgradeable, isReadable, isPayable, isPayableBySmartContract); + + let parts = [ + "upgradeContract", + byteArrayToHex(options.bytecode), + metadata.toString() + ]; + + const preparedArgs = this.argsToDataParts(args, this.abiRegistry?.constructorDefinition) + parts = parts.concat(preparedArgs); + + return new TransactionIntentBuilder({ + config: this.config, + sender: options.sender, + receiver: options.contract, + dataParts: parts, + executionGasLimit: options.gasLimit + }).build(); + } + + private argsToDataParts(args: any[], endpoint?: EndpointDefinition): string[] { + if (endpoint) { + const typedArgs = NativeSerializer.nativeToTypedValues(args, endpoint) + return new ArgSerializer().valuesToStrings(typedArgs); + } + + if (this.areArgsOfTypedValue(args)) { + return new ArgSerializer().valuesToStrings(args); + } + + throw new Err("Can't convert args to TypedValues"); + } + + private areArgsOfTypedValue(args: any[]): boolean { + for (const arg of args) { + if (!(arg.belongsToTypesystem)) { + return false; + } + } + return true; + } +} diff --git a/src/transactionIntentsFactories/transactionIntentBuilder.ts b/src/transactionIntentsFactories/transactionIntentBuilder.ts new file mode 100644 index 000000000..c294f6d2f --- /dev/null +++ b/src/transactionIntentsFactories/transactionIntentBuilder.ts @@ -0,0 +1,59 @@ +import { BigNumber } from "bignumber.js"; +import { IAddress, ITransactionPayload } from "../interface"; +import { ARGUMENTS_SEPARATOR } from "../constants"; +import { TransactionPayload } from "../transactionPayload"; +import { TransactionIntent } from "../transactionIntent"; + +interface Config { + minGasLimit: BigNumber.Value; + gasLimitPerByte: BigNumber.Value; +} + +export class TransactionIntentBuilder { + private config: Config; + private sender: IAddress; + private receiver: IAddress; + private dataParts: string[]; + private executionGasLimit: BigNumber.Value; + private value?: BigNumber.Value; + + constructor(options: { + config: Config, + sender: IAddress, + receiver: IAddress, + dataParts: string[], + executionGasLimit: BigNumber.Value, + value?: BigNumber.Value + }) { + this.config = options.config; + this.sender = options.sender; + this.receiver = options.receiver; + this.dataParts = options.dataParts; + this.executionGasLimit = options.executionGasLimit; + this.value = options.value; + } + + private computeGasLimit(payload: ITransactionPayload, executionGasLimit: BigNumber.Value): BigNumber.Value { + const dataMovementGas = new BigNumber(this.config.minGasLimit).plus(new BigNumber(this.config.gasLimitPerByte).multipliedBy(payload.length())); + const gasLimit = dataMovementGas.plus(executionGasLimit); + return gasLimit; + } + + private buildTransactionPayload(): TransactionPayload { + const data = this.dataParts.join(ARGUMENTS_SEPARATOR); + return new TransactionPayload(data); + } + + build(): TransactionIntent { + const data = this.buildTransactionPayload() + const gasLimit = this.computeGasLimit(data, this.executionGasLimit); + + return new TransactionIntent({ + sender: this.sender.bech32(), + receiver: this.receiver.bech32(), + gasLimit: gasLimit, + value: this.value || 0, + data: data.valueOf() + }) + } +} diff --git a/src/utils.codec.spec.ts b/src/utils.codec.spec.ts index 1ab1660ba..ba38f632c 100644 --- a/src/utils.codec.spec.ts +++ b/src/utils.codec.spec.ts @@ -1,5 +1,5 @@ import { assert } from "chai"; -import { isPaddedHex, numberToPaddedHex, zeroPadStringIfOddLength } from "./utils.codec"; +import { isPaddedHex, numberToPaddedHex, zeroPadStringIfOddLength, byteArrayToHex, utf8ToHex } from "./utils.codec"; describe("test codec utils", () => { it("should convert numberToPaddedHex", () => { @@ -21,4 +21,18 @@ describe("test codec utils", () => { assert.equal(zeroPadStringIfOddLength("1"), "01"); assert.equal(zeroPadStringIfOddLength("01"), "01"); }); + + it("should convert byteArrayToHex", () => { + const firstArray = new Uint8Array([0x05, 0x00]); + const secondArray = new Uint8Array([0x7]); + + assert.equal(byteArrayToHex(firstArray), "0500"); + assert.equal(byteArrayToHex(secondArray), "07"); + }); + + it("should convert utf8ToHex", () => { + assert.equal(utf8ToHex("stringandnumber7"), "737472696e67616e646e756d62657237"); + assert.equal(utf8ToHex("somestring"), "736f6d65737472696e67"); + assert.equal(utf8ToHex("aaa"), "616161"); + }); }); diff --git a/src/utils.codec.ts b/src/utils.codec.ts index 8867c889f..2d79785b5 100644 --- a/src/utils.codec.ts +++ b/src/utils.codec.ts @@ -20,3 +20,12 @@ export function zeroPadStringIfOddLength(input: string): string { return input; } + +export function utf8ToHex(value: string) { + const hex = Buffer.from(value).toString("hex"); + return zeroPadStringIfOddLength(hex); +} + +export function byteArrayToHex(byteArray: Uint8Array): string { + return Buffer.from(byteArray).toString("hex"); +}