From fb7bc00b2e9d8af881cbec8cfe5a436a35b69669 Mon Sep 17 00:00:00 2001 From: naz_dou <41945483+nduchak@users.noreply.github.com> Date: Sat, 22 Jun 2019 13:38:26 +0300 Subject: [PATCH] feat(ACI): Refactor ACI module (#505) * feat(Selector): If default account `address` not provided use the first * feat(ACI): Refactor ACI. Split to separated files. Fix test. * feat(ACI): Refactor building of contract instance method, add ability to init instance without client * feat(ACI): Handle ACI without init function * chore(ACI): Cleanup code * feat(ACI): Refactor ACI module. Fix bug with empty init function. Automatically decide to use on-chai * fix(ACI): Fix options composition when building function * chore(docs): Fix ACI docs example --- es/account/index.js | 5 +- es/account/selector.js | 3 +- es/accounts.js | 3 +- es/ae/aepp.js | 2 +- es/ae/contract.js | 20 +- es/ae/index.js | 2 +- es/ae/universal.js | 11 +- es/ae/wallet.js | 2 +- es/contract/aci.js | 540 ------------------ es/contract/aci/helpers.js | 69 +++ es/contract/aci/index.js | 194 +++++++ es/contract/aci/transformation.js | 315 ++++++++++ es/index.js | 2 + .../aepp/src/components/Home.vue | 2 +- .../identity/src/components/Home.vue | 7 +- test/integration/contract.js | 3 +- test/integration/index.js | 23 +- 17 files changed, 633 insertions(+), 570 deletions(-) delete mode 100644 es/contract/aci.js create mode 100644 es/contract/aci/helpers.js create mode 100644 es/contract/aci/index.js create mode 100644 es/contract/aci/transformation.js diff --git a/es/account/index.js b/es/account/index.js index 3bf3842265..0270b8e0c1 100644 --- a/es/account/index.js +++ b/es/account/index.js @@ -36,15 +36,16 @@ const DEFAULT_NETWORK_ID = `ae_mainnet` * @category async * @rtype (tx: String) => tx: Promise[String], throws: Error * @param {String} tx - Transaction to sign + * @param {Object} opt - Options * @return {String} Signed transaction */ -async function signTransaction (tx) { +async function signTransaction (tx, opt = {}) { const networkId = this.getNetworkId() const rlpBinaryTx = Crypto.decodeBase64Check(Crypto.assertedType(tx, 'tx')) // Prepend `NETWORK_ID` to begin of data binary const txWithNetworkId = Buffer.concat([Buffer.from(networkId), rlpBinaryTx]) - const signatures = [await this.sign(txWithNetworkId)] + const signatures = [await this.sign(txWithNetworkId, opt)] return buildTx({ encodedTx: rlpBinaryTx, signatures }, TX_TYPE.signed).tx } diff --git a/es/account/selector.js b/es/account/selector.js index 9e81078119..75866f59be 100644 --- a/es/account/selector.js +++ b/es/account/selector.js @@ -43,7 +43,7 @@ async function address () { * @param {String} address - Address of account to select * @example selectAccount('ak_xxxxxxxx') */ -async function selectAccount (address) { +function selectAccount (address) { this.Selector.address = address } @@ -58,6 +58,7 @@ async function selectAccount (address) { */ const Selector = Account.compose({ async init ({ address }) { + if (!address) address = Object.keys(this.accounts)[0] this.Selector.address = address }, methods: { sign, address, selectAccount }, diff --git a/es/accounts.js b/es/accounts.js index 421640cc60..e9f8aaf001 100644 --- a/es/accounts.js +++ b/es/accounts.js @@ -36,9 +36,10 @@ async function signWith (address, data) { return account.sign(data) } -async function addAccount (account) { +async function addAccount (account, { select } = {}) { const address = await account.address() this.accounts[address] = account + if (select) this.selectAccount(address) } /** diff --git a/es/ae/aepp.js b/es/ae/aepp.js index 268e5c2aee..df272b221e 100644 --- a/es/ae/aepp.js +++ b/es/ae/aepp.js @@ -24,8 +24,8 @@ import Ae from './' import Aens from './aens' -import { Contract } from './contract' import Rpc from '../rpc/client' +import { Contract } from './contract' /** * Aepp Stamp diff --git a/es/ae/contract.js b/es/ae/contract.js index 19858f3f16..44be5753f0 100644 --- a/es/ae/contract.js +++ b/es/ae/contract.js @@ -31,8 +31,8 @@ import Ae from './' import * as R from 'ramda' import { isBase64 } from '../utils/crypto' import ContractCompilerAPI from '../contract/compiler' -import ContractACI from '../contract/aci' import ContractBase from '../contract' +import ContractACI from '../contract/aci' /** * Handle contract call error @@ -108,23 +108,25 @@ async function contractDecodeData (source, fn, callValue, callResult, options) { */ async function contractCallStatic (source, address, name, args = [], { top, options = {} } = {}) { const opt = R.merge(this.Ae.defaults, options) - - // Prepare `call` transaction - const tx = await this.contractCallTx(R.merge(opt, { - callerId: await this.address(), - contractId: address, - callData: await this.contractEncodeCall(source, name, args) - })) + const callerId = await this.address() // Get block hash by height if (top && !isNaN(top)) { top = (await this.getKeyBlock(top)).hash } + // Prepare `call` transaction + const tx = await this.contractCallTx(R.merge(opt, { + callerId, + contractId: address, + callData: await this.contractEncodeCall(source, name, args), + nonce: top ? (await this.getAccount(callerId, { hash: top })).nonce + 1 : undefined + })) + // Dry-run const [{ result: status, callObj, reason }] = (await this.txDryRun([tx], [{ amount: opt.amount, - pubKey: await this.address() + pubKey: callerId }], top)).results // check response diff --git a/es/ae/index.js b/es/ae/index.js index a22df7e376..0b63cff133 100644 --- a/es/ae/index.js +++ b/es/ae/index.js @@ -42,7 +42,7 @@ import { BigNumber } from 'bignumber.js' */ async function send (tx, options) { const opt = R.merge(this.Ae.defaults, options) - const signed = await this.signTransaction(tx) + const signed = await this.signTransaction(tx, opt) return this.sendTransaction(signed, opt) } diff --git a/es/ae/universal.js b/es/ae/universal.js index 2ee5f6b4f9..4174ab7559 100644 --- a/es/ae/universal.js +++ b/es/ae/universal.js @@ -26,9 +26,11 @@ import Ae from './' import Account from '../account/memory' import Chain from '../chain/node' import Aens from './aens' -import Contract from './contract' import Transaction from '../tx/tx' import Oracle from './oracle' +import Selector from '../account/selector' +import Accounts from '../accounts' +import Contract from './contract' /** * Universal Stamp @@ -41,7 +43,12 @@ import Oracle from './oracle' * @param {Object} [options={}] - Initializer object * @return {Object} Universal instance */ -const Universal = Ae.compose(Account, Chain, Transaction, Aens, Contract, Oracle, { +export const Universal = Ae.compose(Account, Chain, Transaction, Aens, Contract, Oracle, { + init () {}, + props: { process: {} } +}) + +export const UniversalWithAccounts = Ae.compose(Accounts, Chain, Transaction, Aens, Contract, Oracle, Selector, { init () {}, props: { process: {} } }) diff --git a/es/ae/wallet.js b/es/ae/wallet.js index 51bd2cc1cc..4de8b854d2 100644 --- a/es/ae/wallet.js +++ b/es/ae/wallet.js @@ -25,13 +25,13 @@ import Ae from './' import Account from '../account' import ContractBase from '../contract' -import Contract from './contract' import Accounts from '../accounts' import Chain from '../chain/node' import Rpc from '../rpc/server' import Selector from '../account/selector' import * as R from 'ramda' import Tx from '../tx/tx' +import Contract from './contract' const contains = R.flip(R.contains) const isTxMethod = contains(Tx.compose.deepConfiguration.Ae.methods) diff --git a/es/contract/aci.js b/es/contract/aci.js deleted file mode 100644 index 8ab63285ea..0000000000 --- a/es/contract/aci.js +++ /dev/null @@ -1,540 +0,0 @@ -/* eslint-disable no-unused-vars */ -/* - * ISC License (ISC) - * Copyright (c) 2018 aeternity developers - * - * Permission to use, copy, modify, and/or distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH - * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY - * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, - * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM - * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR - * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR - * PERFORMANCE OF THIS SOFTWARE. - */ - -/** - * ContractACI module - * - * @module @aeternity/aepp-sdk/es/contract/aci - * @export ContractACI - * @example import ContractACI from '@aeternity/aepp-sdk/es/contract/aci' - */ -import Joi from 'joi-browser' - -import AsyncInit from '../utils/async-init' -import * as R from 'ramda' - -const SOPHIA_TYPES = [ - 'int', - 'string', - 'tuple', - 'address', - 'bool', - 'list', - 'map', - 'record', - 'option', - 'oracle', - 'oracleQuery', - 'hash', - 'signature', - 'bytes' -].reduce((acc, type) => ({ ...acc, [type]: type }), {}) - -/** - * Transform decoded data to JS type - * @param aci - * @param result - * @param transformDecodedData - * @return {*} - */ -function transformDecodedData (aci, result, { skipTransformDecoded = false, addressPrefix = 'ak', bindings } = {}) { - if (skipTransformDecoded) return result - const { t, generic } = readType(aci, { bindings }) - - switch (t) { - case SOPHIA_TYPES.bool: - return !!result - case SOPHIA_TYPES.address: - return result === 0 - ? 0 - : result - case SOPHIA_TYPES.hash: - case SOPHIA_TYPES.bytes: - case SOPHIA_TYPES.signature: - return result.split('#')[1] - case SOPHIA_TYPES.map: - const [keyT, valueT] = generic - return result - .reduce( - (acc, [key, val]) => { - key = transformDecodedData(keyT, key, { bindings }) - val = transformDecodedData(valueT, val, { bindings }) - acc.push([key, val]) - return acc - }, - [] - ) - case SOPHIA_TYPES.option: - if (result === 'None') return undefined - const [[variantType, [value]]] = Object.entries(result) - return variantType === 'Some' ? transformDecodedData(generic, value, { bindings }) : undefined - case SOPHIA_TYPES.list: - return result.map((value) => transformDecodedData(generic, value, { bindings })) - case SOPHIA_TYPES.tuple: - return result.map((value, i) => { return transformDecodedData(generic[i], value, { bindings }) }) - case SOPHIA_TYPES.record: - const genericMap = generic.reduce((acc, val) => ({ ...acc, [val.name]: { type: val.type } }), {}) - return Object.entries(result).reduce( - (acc, [name, value]) => - ({ - ...acc, - [name]: transformDecodedData(genericMap[name].type, value, { bindings }) - }), - {} - ) - } - return result -} - -/** - * Transform JS type to Sophia-type - * @param type - * @param value - * @param compilerVersion - * @param bindings - * @return {string} - */ -async function transform (type, value, { compilerVersion, bindings } = {}) { - let { t, generic } = readType(type, { bindings }) - - // contract TestContract = ... - // fn(ct: TestContract) - if (typeof value === 'string' && value.slice(0, 2) === 'ct') t = SOPHIA_TYPES.address // Handle Contract address transformation - - switch (t) { - case SOPHIA_TYPES.string: - return `"${value}"` - case SOPHIA_TYPES.list: - return `[${await Promise.all(value.map(async el => transform(generic, el, { compilerVersion, bindings })))}]` - case SOPHIA_TYPES.tuple: - return `(${await Promise.all(value.map(async (el, i) => transform(generic[i], el, { - compilerVersion, - bindings - })))})` - case SOPHIA_TYPES.option: - const optionV = await value.catch(e => undefined) - return optionV === undefined ? 'None' : `Some(${await transform(generic, optionV, { - compilerVersion, - bindings - })})` - case SOPHIA_TYPES.hash: - case SOPHIA_TYPES.bytes: - case SOPHIA_TYPES.signature: - return `#${typeof value === 'string' ? value : Buffer.from(value).toString('hex')}` - case SOPHIA_TYPES.record: - return `{${await generic.reduce( - async (acc, { name, type }, i) => { - acc = await acc - acc += `${i !== 0 ? ',' : ''}${name} = ${await transform(type, value[name], { - compilerVersion, - bindings - })}` - return acc - }, - '' - )}}` - case SOPHIA_TYPES.map: - return transformMap(value, generic, { compilerVersion, bindings }) - } - - return `${value}` -} - -async function transformMap (value, generic, { compilerVersion, bindings }) { - if (value instanceof Map) { - value = Array.from(value.entries()) - } - if (!Array.isArray(value) && value instanceof Object) { - value = Object.entries(value) - } - - return `{${await value - .reduce( - async (acc, [key, value], i) => { - acc = await acc - if (i !== 0) acc += ',' - acc += `[${await transform(generic[0], key, { - compilerVersion, - bindings - })}] = ${await transform(generic[1], value, { compilerVersion, bindings })}` - return acc - }, - `` - ) - }}` -} - -function linkTypeDefs (t, bindings) { - const [_, typeDef] = t.split('.') - const aciType = [ - ...bindings.typedef, - { name: 'state', typedef: bindings.state } - ].find(({ name }) => name === typeDef) - return aciType.typedef -} -/** - * Parse sophia type - * @param type - * @param returnType - * @return {*} - */ -function readType (type, { bindings } = {}) { - let [t] = Array.isArray(type) ? type : [type] - - // Link State and typeDef - if (typeof t === 'string' && t.indexOf(bindings.contractName) !== -1) { - t = linkTypeDefs(t, bindings) - } - // Map, Tuple, List, Record, Bytes - if (typeof t === 'object') { - const [[baseType, generic]] = Object.entries(t) - return { t: baseType, generic } - } - - // Base types - if (typeof t === 'string') return { t } -} -const customJoi = Joi.extend((joi) => ({ - name: 'binary', - base: joi.any(), - pre (value, state, options) { - if (options.convert && typeof value === 'string') { - try { - return Buffer.from(value, 'hex') - } catch (e) { return undefined } - } - - return Buffer.from(value) - }, - rules: [ - { - name: 'bufferCheck', - params: { - size: joi.number().required() - }, - validate (params, value, state, options) { - value = value === 'string' ? Buffer.from(value, 'hex') : Buffer.from(value) - if (!Buffer.isBuffer(value)) { - return this.createError('binary.base', { value }, state, options) - } - if (value.length !== params.size) { - return this.createError('binary.bufferCheck', { value, size: params.size }, state, options) - } - - return value - } - } - ] -})) -/** - * Prepare Joi validation schema for sophia types - * @param type - * @param bindings - * @return {Object} JoiSchema - */ -function prepareSchema (type, { bindings } = {}) { - let { t, generic } = readType(type, { bindings }) - if (!Object.keys(SOPHIA_TYPES).includes(t)) t = SOPHIA_TYPES.address // Handle Contract address transformation - switch (t) { - case SOPHIA_TYPES.int: - return Joi.number().error(getJoiErrorMsg) - case SOPHIA_TYPES.string: - return Joi.string().error(getJoiErrorMsg) - case SOPHIA_TYPES.address: - return Joi.string().regex(/^(ak_|ct_|ok_|oq_)/).error(getJoiErrorMsg) - case SOPHIA_TYPES.bool: - return Joi.boolean().error(getJoiErrorMsg) - case SOPHIA_TYPES.list: - return Joi.array().items(prepareSchema(generic, { bindings })).error(getJoiErrorMsg) - case SOPHIA_TYPES.tuple: - return Joi.array().ordered(generic.map(type => prepareSchema(type, { bindings }).required())).label('Tuple argument').error(getJoiErrorMsg) - case SOPHIA_TYPES.record: - return Joi.object( - generic.reduce((acc, { name, type }) => ({ ...acc, [name]: prepareSchema(type, { bindings }) }), {}) - ).error(getJoiErrorMsg) - case SOPHIA_TYPES.hash: - return customJoi.binary().bufferCheck(32).error(getJoiErrorMsg) - case SOPHIA_TYPES.bytes: - return customJoi.binary().bufferCheck(generic).error(getJoiErrorMsg) - case SOPHIA_TYPES.signature: - return customJoi.binary().bufferCheck(64).error(getJoiErrorMsg) - case SOPHIA_TYPES.option: - return Joi.object().type(Promise).error(getJoiErrorMsg) - // @Todo Need to transform Map to Array of arrays before validating it - // case SOPHIA_TYPES.map: - // return Joi.array().items(Joi.array().ordered(generic.map(type => prepareSchema(type)))) - default: - return Joi.any() - } -} - -function getJoiErrorMsg (errors) { - return errors.map(err => { - const { path, type, context } = err - let value = context.hasOwnProperty('value') ? context.value : context.label - value = typeof value === 'object' ? JSON.stringify(value).slice(1).slice(0, -1) : value - switch (type) { - case 'string.base': - return ({ ...err, message: `Value "${value}" at path: [${path}] not a string` }) - case 'number.base': - return ({ ...err, message: `Value "${value}" at path: [${path}] not a number` }) - case 'boolean.base': - return ({ ...err, message: `Value "${value}" at path: [${path}] not a boolean` }) - case 'array.base': - return ({ ...err, message: `Value "${value}" at path: [${path}] not a array` }) - case 'object.base': - return ({ ...err, message: `Value '${value}' at path: [${path}] not a object` }) - case 'object.type': - return ({ ...err, message: `Value '${value}' at path: [${path}] not a ${context.type}` }) - case 'binary.bufferCheck': - return ({ ...err, message: `Value '${Buffer.from(value).toString('hex')}' at path: [${path}] not a ${context.size} bytes` }) - default: - return err - } - }) -} - -function validateArguments (aci, params) { - const validationSchema = Joi.array().ordered( - aci.arguments - .map(({ type }, i) => prepareSchema(type, { bindings: aci.bindings }).label(`[${params[i]}]`)) - ).label('Argument') - const { error } = Joi.validate(params, validationSchema, { abortEarly: false }) - if (error) { - throw error - } -} - -/** - * Validated contract call arguments using contract ACI - * @function validateCallParams - * @rtype (aci: Object, params: Array) => Object - * @param {Object} aci Contract ACI - * @param {Array} params Contract call arguments - * @param compilerVersion - * @return Promise{Array} Object with validation errors - */ -async function prepareArgsForEncode (aci, params, { compilerVersion } = {}) { - if (!aci) return params - // Validation - validateArguments(aci, params) - const bindings = aci.bindings - // Cast argument from JS to Sophia type - return Promise.all(aci.arguments.map(async ({ type }, i) => transform(type, params[i], { - compilerVersion, - bindings - }))) -} - -/** - * Get function schema from contract ACI object - * @param {Object} aci Contract ACI object - * @param {String} name Function name - * @return {Object} function ACI - */ -function getFunctionACI (aci, name) { - const fn = aci.functions.find(f => f.name === name) - if (!fn && name !== 'init') throw new Error(`Function ${name} doesn't exist in contract`) - - return { - ...fn, - bindings: { - state: aci.state, - typedef: aci.type_defs, - contractName: aci.name - } - } -} - -/** - * Generate contract ACI object with predefined js methods for contract usage - * @alias module:@aeternity/aepp-sdk/es/contract/aci - * @param {String} source Contract source code - * @param {Object} [options] Options object - * @param {Object} [options.aci] Contract ACI - * @param {Object} [options.contractAddress] Contract address - * @param {Object} [options.opt] Contract options - * @return {ContractInstance} JS Contract API - * @example - * const contractIns = await client.getContractInstance(sourceCode) - * await contractIns.compile() - * await contractIns.deploy([321]) - * const callResult = await contractIns.call('setState', [123]) - * const staticCallResult = await contractIns.call('setState', [123], { callStatic: true }) - */ -async function getContractInstance (source, { aci, contractAddress, opt } = {}) { - aci = aci || await this.contractGetACI(source) - const defaultOptions = { - skipArgsConvert: false, - skipTransformDecoded: false, - callStatic: false, - deposit: 0, - gasPrice: 1000000000, // min gasPrice 1e9 - amount: 0, - gas: 1600000 - 21000, - top: null, // using for contract call static - waitMined: true, - verify: false - } - const instance = { - interface: aci.interface, - aci: aci.encoded_aci.contract, - source, - compiled: null, - deployInfo: { address: contractAddress }, - options: R.merge(defaultOptions, opt), - compilerVersion: this.compilerVersion, - setOptions (opt) { - this.options = R.merge(this.options, opt) - } - } - /** - * Compile contract - * @alias module:@aeternity/aepp-sdk/es/contract/aci - * @rtype () => ContractInstance: Object - * @return {ContractInstance} Contract ACI object with predefined js methods for contract usage - */ - instance.compile = compile(this).bind(instance) - /** - * Deploy contract - * @alias module:@aeternity/aepp-sdk/es/contract/aci - * @rtype (init: Array, options: Object = { skipArgsConvert: false }) => ContractInstance: Object - * @param {Array} init Contract init function arguments array - * @param {Object} [options={}] options Options object - * @param {Boolean} [options.skipArgsConvert=false] Skip Validation and Transforming arguments before prepare call-data - * @return {ContractInstance} Contract ACI object with predefined js methods for contract usage - */ - instance.deploy = deploy(this).bind(instance) - /** - * Call contract function - * @alias module:@aeternity/aepp-sdk/es/contract/aci - * @rtype (init: Array, options: Object = { skipArgsConvert: false, skipTransformDecoded: false, callStatic: false }) => CallResult: Object - * @param {String} fn Function name - * @param {Array} params Array of function arguments - * @param {Object} [options={}] Array of function arguments - * @param {Boolean} [options.skipArgsConvert=false] Skip Validation and Transforming arguments before prepare call-data - * @param {Boolean} [options.skipTransformDecoded=false] Skip Transform decoded data to JS type - * @param {Boolean} [options.callStatic=false] Static function call - * @return {Object} CallResult - */ - instance.call = call(this).bind(instance) - - instance.methods = instance - .aci - .functions - .reduce( - (acc, { name }) => ({ - ...acc, - [name]: function () { - return name !== 'init' - ? instance.call(name, Object.values(arguments)) - : instance.deploy(Object.values(arguments)) - } - }), - {} - ) - - return instance -} - -// @TODO Remove after compiler can decode using type from ACI -function transformReturnType (returns, { bindings } = {}) { - if (typeof returns === 'string' && returns.indexOf(bindings.contractName) !== -1) { - returns = linkTypeDefs(returns, bindings) - } - try { - if (typeof returns === 'string') return returns - if (typeof returns === 'object') { - const [[key, value]] = Object.entries(returns) - return `${key !== 'tuple' && key !== 'record' ? key : ''}(${value - .reduce( - (acc, el, i) => { - if (i !== 0) acc += ',' - acc += transformReturnType(key !== 'record' ? el : el.type, { bindings }) - return acc - }, - '')})` - } - } catch (e) { - return null - } -} - -function call (self) { - return async function (fn, params = [], options = {}) { - const opt = R.merge(this.options, options) - const fnACI = getFunctionACI(this.aci, fn) - const source = opt.source || this.source - if (!fn) throw new Error('Function name is required') - if (!this.deployInfo.address) throw new Error('You need to deploy contract before calling!') - - params = !opt.skipArgsConvert ? await prepareArgsForEncode(fnACI, params, { compilerVersion: this.compilerVersion }) : params - const result = opt.callStatic - ? await self.contractCallStatic(source, this.deployInfo.address, fn, params, { - top: opt.top, - opt - }) - : await self.contractCall(source, this.deployInfo.address, fn, params, opt) - return { - ...result, - decodedResult: await transformDecodedData( - fnACI.returns, - await result.decode(), - { ...opt, compilerVersion: this.compilerVersion, bindings: fnACI.bindings } - ) - } - } -} - -function deploy (self) { - return async function (init = [], options = {}) { - const opt = R.merge(this.options, options) - const fnACI = getFunctionACI(this.aci, 'init') - if (!this.compiled) await this.compile() - init = !opt.skipArgsConvert ? await prepareArgsForEncode(fnACI, init, { compilerVersion: this.compilerVersion }) : init - - const { owner, transaction, address, createdAt, result, rawTx } = await self.contractDeploy(this.compiled, opt.source || this.source, init, opt) - this.deployInfo = { owner, transaction, address, createdAt, result, rawTx } - return this - } -} - -function compile (self) { - return async function () { - const { bytecode } = await self.contractCompile(this.source) - this.compiled = bytecode - return this - } -} - -/** - * Contract ACI Stamp - * - * @function - * @alias module:@aeternity/aepp-sdk/es/contract/aci - * @rtype Stamp - * @return {Object} Contract compiler instance - * @example ContractACI() - */ -const ContractACI = AsyncInit.compose({ - methods: { - getContractInstance - } -}) - -export default ContractACI diff --git a/es/contract/aci/helpers.js b/es/contract/aci/helpers.js new file mode 100644 index 0000000000..c657b68d4f --- /dev/null +++ b/es/contract/aci/helpers.js @@ -0,0 +1,69 @@ +import * as R from 'ramda' + +/** + * Get function schema from contract ACI object + * @param {Object} aci Contract ACI object + * @param {String} name Function name + * @return {Object} function ACI + */ +export function getFunctionACI (aci, name) { + const fn = aci.functions.find(f => f.name === name) + if (!fn && name !== 'init') throw new Error(`Function ${name} doesn't exist in contract`) + + return { + ...fn, + bindings: { + state: aci.state, + typedef: aci.type_defs, + contractName: aci.name + } + } +} + +/** + * Build contract methods base on ACI + * @return {Object} Contract instance methods + */ +export const buildContractMethods = (instance) => () => ({ + ...instance.aci ? { + init () { + const { arguments: aciArgs } = getFunctionACI(instance.aci, 'init') + const { opt, args } = parseArguments(aciArgs)(arguments) + return instance.deploy(args, opt) + } + } : {}, + ...instance.aci + ? instance + .aci + .functions + .reduce( + (acc, { name, arguments: aciArgs, stateful }) => ({ + ...acc, + [name]: Object.assign( + function () { + const { opt, args } = parseArguments(aciArgs)(arguments) + if (name === 'init') return instance.deploy(args, opt) + return instance.call(name, args, { callStatic: !stateful, ...opt }) + }, + { + get () { + const { opt, args } = parseArguments(aciArgs)(arguments) + return instance.call(name, args, { ...opt, callStatic: true }) + }, + send () { + const { opt, args } = parseArguments(aciArgs)(arguments) + if (name === 'init') return instance.deploy(args, opt) + return instance.call(name, args, { ...opt, callStatic: false }) + } + } + ) + }), + {} + ) + : {} +}) + +const parseArguments = (aciArgs = []) => (args) => ({ + opt: args.length > aciArgs.length ? R.last(args) : {}, + args: Object.values(args).slice(0, aciArgs.length) +}) diff --git a/es/contract/aci/index.js b/es/contract/aci/index.js new file mode 100644 index 0000000000..af269b6d7e --- /dev/null +++ b/es/contract/aci/index.js @@ -0,0 +1,194 @@ +/* + * ISC License (ISC) + * Copyright (c) 2018 aeternity developers + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + * LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + * PERFORMANCE OF THIS SOFTWARE. + */ + +/** + * ContractACI module + * + * @module @aeternity/aepp-sdk/es/contract/aci + * @export ContractACI + * @example import ContractACI from '@aeternity/aepp-sdk/es/contract/aci' + */ + +import * as R from 'ramda' + +import { validateArguments, transform, transformDecodedData } from './transformation' +import { buildContractMethods, getFunctionACI } from './helpers' +import AsyncInit from '../../utils/async-init' + +/** + * Validated contract call arguments using contract ACI + * @function validateCallParams + * @rtype (aci: Object, params: Array) => Object + * @param {Object} aci Contract ACI + * @param {Array} params Contract call arguments + * @return Promise{Array} Object with validation errors + */ +async function prepareArgsForEncode (aci, params) { + if (!aci || !aci.arguments) return params + // Validation + validateArguments(aci, params) + const bindings = aci.bindings + // Cast argument from JS to Sophia type + return Promise.all(aci.arguments.map(async ({ type }, i) => transform(type, params[i], { + bindings + }))) +} + +/** + * Generate contract ACI object with predefined js methods for contract usage + * @alias module:@aeternity/aepp-sdk/es/contract/aci + * @param {String} source Contract source code + * @param {Object} [options] Options object + * @param {Object} [options.aci] Contract ACI + * @param {Object} [options.contractAddress] Contract address + * @param {Object} [options.opt] Contract options + * @return {ContractInstance} JS Contract API + * @example + * const contractIns = await client.getContractInstance(sourceCode) + * await contractIns.deploy([321]) or await contractIns.methods.init(321) + * const callResult = await contractIns.call('setState', [123]) or await contractIns.methods.setState.send(123, options) + * const staticCallResult = await contractIns.call('setState', [123], { callStatic: true }) or await contractIns.methods.setState.get(123, options) + * Also you can call contract like: await contractIns.methods.setState(123, options) + * Then sdk decide to make on-chain or static call(dry-run API) transaction based on function is stateful or not + */ +async function getContractInstance (source, { aci, contractAddress, opt } = {}) { + aci = aci || await this.contractGetACI(source) + const defaultOptions = { + skipArgsConvert: false, + skipTransformDecoded: false, + callStatic: false, + deposit: 0, + gasPrice: 1000000000, // min gasPrice 1e9 + amount: 0, + gas: 1600000 - 21000, + top: null, // using for contract call static + waitMined: true, + verify: false + } + const instance = { + interface: R.defaultTo(null, R.prop('interface', aci)), + aci: R.defaultTo(null, R.path(['encoded_aci', 'contract'], aci)), + source, + compiled: null, + deployInfo: { address: contractAddress }, + options: R.merge(defaultOptions, opt), + compilerVersion: this.compilerVersion, + setOptions (opt) { + this.options = R.merge(this.options, opt) + } + } + + /** + * Compile contract + * @alias module:@aeternity/aepp-sdk/es/contract/aci + * @rtype () => ContractInstance: Object + * @return {ContractInstance} Contract ACI object with predefined js methods for contract usage + */ + instance.compile = compile({ client: this, instance }) + /** + * Deploy contract + * @alias module:@aeternity/aepp-sdk/es/contract/aci + * @rtype (init: Array, options: Object = { skipArgsConvert: false }) => ContractInstance: Object + * @param {Array} init Contract init function arguments array + * @param {Object} [options={}] options Options object + * @param {Boolean} [options.skipArgsConvert=false] Skip Validation and Transforming arguments before prepare call-data + * @return {ContractInstance} Contract ACI object with predefined js methods for contract usage + */ + instance.deploy = deploy({ client: this, instance }) + /** + * Call contract function + * @alias module:@aeternity/aepp-sdk/es/contract/aci + * @rtype (init: Array, options: Object = { skipArgsConvert: false, skipTransformDecoded: false, callStatic: false }) => CallResult: Object + * @param {String} fn Function name + * @param {Array} params Array of function arguments + * @param {Object} [options={}] Array of function arguments + * @param {Boolean} [options.skipArgsConvert=false] Skip Validation and Transforming arguments before prepare call-data + * @param {Boolean} [options.skipTransformDecoded=false] Skip Transform decoded data to JS type + * @param {Boolean} [options.callStatic=false] Static function call + * @return {Object} CallResult + */ + instance.call = call({ client: this, instance }) + + /** + * Generate proto function based on contract function using Contract ACI schema + * All function can be called like: + * 'await contract.methods.testFunction()' -> then sdk will decide to use dry-run or send tx on-chain base on if function stateful or not. + * Also you can manually do that: + * `await contract.methods.testFunction.get()` -> use call-static(dry-run) + * `await contract.methods.testFunction.send()` -> send tx on-chain + */ + instance.methods = buildContractMethods(instance)() + return instance +} + +const call = ({ client, instance }) => async (fn, params = [], options = {}) => { + const opt = R.merge(instance.options, options) + const fnACI = getFunctionACI(instance.aci, fn) + const source = opt.source || instance.source + + if (!fn) throw new Error('Function name is required') + if (!instance.deployInfo.address) throw new Error('You need to deploy contract before calling!') + + params = !opt.skipArgsConvert ? await prepareArgsForEncode(fnACI, params) : params + const result = opt.callStatic + ? await client.contractCallStatic(source, instance.deployInfo.address, fn, params, { + top: opt.top, + options: opt + }) + : await client.contractCall(source, instance.deployInfo.address, fn, params, opt) + return { + ...result, + decodedResult: await transformDecodedData( + fnACI.returns, + await result.decode(), + { ...opt, compilerVersion: instance.compilerVersion, bindings: fnACI.bindings } + ) + } +} + +const deploy = ({ client, instance }) => async (init = [], options = {}) => { + const opt = R.merge(instance.options, options) + const fnACI = getFunctionACI(instance.aci, 'init') + + if (!instance.compiled) await instance.compile() + init = !opt.skipArgsConvert ? await prepareArgsForEncode(fnACI, init) : init + + const { owner, transaction, address, createdAt, result, rawTx } = await client.contractDeploy(instance.compiled, opt.source || instance.source, init, opt) + instance.deployInfo = { owner, transaction, address, createdAt, result, rawTx } + return instance.deployInfo +} + +const compile = ({ client, instance }) => async () => { + const { bytecode } = await client.contractCompile(instance.source) + instance.compiled = bytecode + return instance.compiled +} + +/** + * Contract ACI Stamp + * + * @function + * @alias module:@aeternity/aepp-sdk/es/contract/aci + * @rtype Stamp + * @return {Object} Contract compiler instance + * @example ContractACI() + */ +export default AsyncInit.compose({ + methods: { + getContractInstance + } +}) diff --git a/es/contract/aci/transformation.js b/es/contract/aci/transformation.js new file mode 100644 index 0000000000..f0280209c3 --- /dev/null +++ b/es/contract/aci/transformation.js @@ -0,0 +1,315 @@ +/* eslint-disable no-unused-vars */ +import Joi from 'joi-browser' + +export const SOPHIA_TYPES = [ + 'int', + 'string', + 'tuple', + 'address', + 'bool', + 'list', + 'map', + 'record', + 'option', + 'oracle', + 'oracleQuery', + 'hash', + 'signature', + 'bytes' +].reduce((acc, type) => ({ ...acc, [type]: type }), {}) + +/** + * Ling Type Defs + * @param t + * @param bindings + * @return {Object} + */ +export function linkTypeDefs (t, bindings) { + const [_, typeDef] = t.split('.') + const aciType = [ + ...bindings.typedef, + { name: 'state', typedef: bindings.state } + ].find(({ name }) => name === typeDef) + return aciType.typedef +} + +/** + * Parse sophia type + * @param type + * @param returnType + * @return {Object} + */ +export function readType (type, { bindings } = {}) { + let [t] = Array.isArray(type) ? type : [type] + + // Link State and typeDef + if (typeof t === 'string' && t.indexOf(bindings.contractName) !== -1) { + t = linkTypeDefs(t, bindings) + } + // Map, Tuple, List, Record, Bytes + if (typeof t === 'object') { + const [[baseType, generic]] = Object.entries(t) + return { t: baseType, generic } + } + // Base types + if (typeof t === 'string') return { t } +} + +// FUNCTION ARGUMENTS TRANSFORMATION ↓↓↓ + +/** + * Transform JS type to Sophia-type + * @param type + * @param value + * @param bindings + * @return {string} + */ +export async function transform (type, value, { bindings } = {}) { + let { t, generic } = readType(type, { bindings }) + + switch (t) { + case SOPHIA_TYPES.string: + return `"${value}"` + case SOPHIA_TYPES.list: + return `[${await Promise.all(value.map(async el => transform(generic, el, { bindings })))}]` + case SOPHIA_TYPES.tuple: + return `(${await Promise.all(value.map(async (el, i) => transform(generic[i], el, { + bindings + })))})` + case SOPHIA_TYPES.option: + const optionV = await value.catch(e => undefined) + return optionV === undefined ? 'None' : `Some(${await transform(generic, optionV, { + bindings + })})` + case SOPHIA_TYPES.hash: + case SOPHIA_TYPES.bytes: + case SOPHIA_TYPES.signature: + return `#${typeof value === 'string' ? value : Buffer.from(value).toString('hex')}` + case SOPHIA_TYPES.record: + return `{${await generic.reduce( + async (acc, { name, type }, i) => { + acc = await acc + acc += `${i !== 0 ? ',' : ''}${name} = ${await transform(type, value[name], { + bindings + })}` + return acc + }, + '' + )}}` + case SOPHIA_TYPES.map: + return transformMap(value, generic, { bindings }) + } + + return `${value}` +} + +export async function transformMap (value, generic, { bindings }) { + if (value instanceof Map) { + value = Array.from(value.entries()) + } + if (!Array.isArray(value) && value instanceof Object) { + value = Object.entries(value) + } + + return `{${await value + .reduce( + async (acc, [key, value], i) => { + acc = await acc + if (i !== 0) acc += ',' + acc += `[${await transform(generic[0], key, { + bindings + })}] = ${await transform(generic[1], value, { bindings })}` + return acc + }, + `` + ) + }}` +} + +// FUNCTION RETURN VALUE TRANSFORMATION ↓↓↓ + +/** + * Transform decoded data to JS type + * @param aci + * @param result + * @param transformDecodedData + * @return {*} + */ +export function transformDecodedData (aci, result, { skipTransformDecoded = false, addressPrefix = 'ak', bindings } = {}) { + if (skipTransformDecoded) return result + const { t, generic } = readType(aci, { bindings }) + + switch (t) { + case SOPHIA_TYPES.bool: + return !!result + case SOPHIA_TYPES.address: + return result === 0 + ? 0 + : result + case SOPHIA_TYPES.hash: + case SOPHIA_TYPES.bytes: + case SOPHIA_TYPES.signature: + return result.split('#')[1] + case SOPHIA_TYPES.map: + const [keyT, valueT] = generic + return result + .reduce( + (acc, [key, val]) => { + key = transformDecodedData(keyT, key, { bindings }) + val = transformDecodedData(valueT, val, { bindings }) + acc.push([key, val]) + return acc + }, + [] + ) + case SOPHIA_TYPES.option: + if (result === 'None') return undefined + const [[variantType, [value]]] = Object.entries(result) + return variantType === 'Some' ? transformDecodedData(generic, value, { bindings }) : undefined + case SOPHIA_TYPES.list: + return result.map((value) => transformDecodedData(generic, value, { bindings })) + case SOPHIA_TYPES.tuple: + return result.map((value, i) => { return transformDecodedData(generic[i], value, { bindings }) }) + case SOPHIA_TYPES.record: + const genericMap = generic.reduce((acc, val) => ({ ...acc, [val.name]: { type: val.type } }), {}) + return Object.entries(result).reduce( + (acc, [name, value]) => + ({ + ...acc, + [name]: transformDecodedData(genericMap[name].type, value, { bindings }) + }), + {} + ) + } + return result +} + +// FUNCTION ARGUMENTS VALIDATION ↓↓↓ + +/** + * Prepare Joi validation schema for sophia types + * @param type + * @param bindings + * @return {Object} JoiSchema + */ +export function prepareSchema (type, { bindings } = {}) { + let { t, generic } = readType(type, { bindings }) + if (!Object.keys(SOPHIA_TYPES).includes(t)) t = SOPHIA_TYPES.address // Handle Contract address transformation + switch (t) { + case SOPHIA_TYPES.int: + return Joi.number().error(getJoiErrorMsg) + case SOPHIA_TYPES.string: + return Joi.string().error(getJoiErrorMsg) + case SOPHIA_TYPES.address: + return Joi.string().regex(/^(ak_|ct_|ok_|oq_)/).error(getJoiErrorMsg) + case SOPHIA_TYPES.bool: + return Joi.boolean().error(getJoiErrorMsg) + case SOPHIA_TYPES.list: + return Joi.array().items(prepareSchema(generic, { bindings })).error(getJoiErrorMsg) + case SOPHIA_TYPES.tuple: + return Joi.array().ordered(generic.map(type => prepareSchema(type, { bindings }).required())).label('Tuple argument').error(getJoiErrorMsg) + case SOPHIA_TYPES.record: + return Joi.object( + generic.reduce((acc, { name, type }) => ({ ...acc, [name]: prepareSchema(type, { bindings }) }), {}) + ).error(getJoiErrorMsg) + case SOPHIA_TYPES.hash: + return JoiBinary.binary().bufferCheck(32).error(getJoiErrorMsg) + case SOPHIA_TYPES.bytes: + return JoiBinary.binary().bufferCheck(generic).error(getJoiErrorMsg) + case SOPHIA_TYPES.signature: + return JoiBinary.binary().bufferCheck(64).error(getJoiErrorMsg) + case SOPHIA_TYPES.option: + return Joi.object().type(Promise).error(getJoiErrorMsg) + // @Todo Need to transform Map to Array of arrays before validating it + // case SOPHIA_TYPES.map: + // return Joi.array().items(Joi.array().ordered(generic.map(type => prepareSchema(type)))) + default: + return Joi.any() + } +} + +/** + * Parse Joi validation error message + * @param errors + * @return {Object} JoiError + */ +export function getJoiErrorMsg (errors) { + return errors.map(err => { + const { path, type, context } = err + let value = context.hasOwnProperty('value') ? context.value : context.label + value = typeof value === 'object' ? JSON.stringify(value).slice(1).slice(0, -1) : value + switch (type) { + case 'string.base': + return ({ ...err, message: `Value "${value}" at path: [${path}] not a string` }) + case 'number.base': + return ({ ...err, message: `Value "${value}" at path: [${path}] not a number` }) + case 'boolean.base': + return ({ ...err, message: `Value "${value}" at path: [${path}] not a boolean` }) + case 'array.base': + return ({ ...err, message: `Value "${value}" at path: [${path}] not a array` }) + case 'object.base': + return ({ ...err, message: `Value '${value}' at path: [${path}] not a object` }) + case 'object.type': + return ({ ...err, message: `Value '${value}' at path: [${path}] not a ${context.type}` }) + case 'binary.bufferCheck': + return ({ + ...err, + message: `Value '${Buffer.from(value).toString('hex')}' at path: [${path}] not a ${context.size} bytes` + }) + default: + return err + } + }) +} + +/** + * Custom Joi Validator for binary type + */ +const JoiBinary = Joi.extend((joi) => ({ + name: 'binary', + base: joi.any(), + pre (value, state, options) { + if (options.convert && typeof value === 'string') { + try { + return Buffer.from(value, 'hex') + } catch (e) { return undefined } + } + + return Buffer.from(value) + }, + rules: [ + { + name: 'bufferCheck', + params: { + size: joi.number().required() + }, + validate (params, value, state, options) { + value = value === 'string' ? Buffer.from(value, 'hex') : Buffer.from(value) + if (!Buffer.isBuffer(value)) { + return this.createError('binary.base', { value }, state, options) + } + if (value.length !== params.size) { + return this.createError('binary.bufferCheck', { value, size: params.size }, state, options) + } + + return value + } + } + ] +})) + +/** + * Validation contract function arguments + * @param aci + * @param params + */ +export function validateArguments (aci, params) { + const validationSchema = Joi.array().ordered( + aci.arguments + .map(({ type }, i) => prepareSchema(type, { bindings: aci.bindings }).label(`[${params[i]}]`)) + ).label('Argument') + const { error } = Joi.validate(params, validationSchema, { abortEarly: false }) + if (error) { + throw error + } +} diff --git a/es/index.js b/es/index.js index 1104c2fa01..c2f131f009 100644 --- a/es/index.js +++ b/es/index.js @@ -38,6 +38,7 @@ import OracleNodeAPI from './oracle/node' import Selector from './account/selector' import Channel from './channel' import Universal from './ae/universal' +import ContractACI from './contract/aci' export { Account, @@ -46,6 +47,7 @@ export { Aepp, Contract, ContractCompilerAPI, + ContractACI, ChainNode, Channel, Crypto, diff --git a/examples/browser/vuejs/connect-two-ae/aepp/src/components/Home.vue b/examples/browser/vuejs/connect-two-ae/aepp/src/components/Home.vue index 5531095611..f8f60e9cc5 100644 --- a/examples/browser/vuejs/connect-two-ae/aepp/src/components/Home.vue +++ b/examples/browser/vuejs/connect-two-ae/aepp/src/components/Home.vue @@ -142,7 +142,7 @@