diff --git a/build.js b/build.js index ab01493..f2491f8 100644 --- a/build.js +++ b/build.js @@ -10,9 +10,9 @@ const os = require('os') const buildTime = moment().format('HH:mm:ss DD/MM/YYYY') const serverUrls = { - 'localnet': 'http://localhost:4096', - 'mainnet': 'http://mainnet.asch.cn', - 'testnet': 'http://testnet.asch.io' + localnet: 'http://localhost:4096', + mainnet: 'http://mainnet.asch.cn', + testnet: 'http://testnet.asch.io', } function build(osVersion, netVersion) { @@ -44,8 +44,7 @@ function build(osVersion, netVersion) { if (osVersion === 'darwin') { // macOS using *bsd sed which doesn't support --version option // and user may install gnu-sed to replace it. - if (shell.exec(`sed --version`, { silent: true }).code != 0) - sedi = 'sed -i \'\'' + if (shell.exec('sed --version', { silent: true }).code !== 0) { sedi = 'sed -i \'\'' } } shell.cp('-r', 'app.js', 'src', fullPath) @@ -58,7 +57,7 @@ function build(osVersion, netVersion) { let frontendRepo = 'https://github.com/AschPlatform/asch-frontend-2.git' if (process.env.ASCH_FRONTEND_REPO != null) { frontendRepo = process.env.ASCH_FRONTEND_REPO - console.log("Using Frontend Repo:", frontendRepo) + console.log('Using Frontend Repo:', frontendRepo) } else { console.log('PS, you may use env ASCH_FRONTEND_REPO to use a faster repo ...') } @@ -74,8 +73,7 @@ function build(osVersion, netVersion) { // prepare frontend source code const magic = shell.exec('grep magic config.json | awk \'{print $2}\' | sed -e \'s/[",]//g\'', { silent: true }).stdout.trim() let branch = shell.exec('git branch | grep \\* | cut -d \' \' -f2', { silent: true }).stdout.trim() - if (branch !== 'master' && branch !== 'develop' ) - branch = 'develop' + if (branch !== 'master' && branch !== 'develop') { branch = 'develop' } // It is quite possible that last build stop before cleanup frontend files if (shell.test('-e', `${fullPath}/tmp/asch-frontend-2`)) { shell.rm('-rf', `${fullPath}/tmp/asch-frontend-2`, { silent: true }) diff --git a/src/contract/contract.js b/src/contract/contract.js new file mode 100644 index 0000000..d3c8449 --- /dev/null +++ b/src/contract/contract.js @@ -0,0 +1,228 @@ +const CONTRACT_ID_SEQUENCE = 'contract_sequence' +const CONTRACT_TRANSFER_ID_SEQUENCE = 'contract_transfer_sequence' +const GAS_CURRENCY = 'BCH' +const XAS_CURRENCY = 'XAS' +const CONTRACT_MODEL = 'Contract' +const CONTRACT_RESULT_MODEL = 'ContractResult' +const ACCOUNT_MODEL = 'Account' +const CONTRACT_TRANSFER_MODEL = 'ContractTransfer' +const GAS_BUY_BACK_ADDRESS = 'ARepurchaseAddr1234567890123456789' +const PAY_METHOD = 'onPay' +const MAX_GAS_LIMIT = 10000000 // 0.1BCH + +function require(condition, error) { + if (!condition) throw Error(error) +} + +function makeContractAddress(transId, ownerAddress) { + return app.util.address.generateContractAddress(`${transId}_${ownerAddress}`) +} + +function makeContext(senderAddress, transaction, block) { + return { senderAddress, transaction, block } +} + +async function ensureBCHEnough(address, amount, gasOnly) { + const bchAvalible = app.balances.get(address, GAS_CURRENCY) + if (!gasOnly) { + require(bchAvalible.gte(amount), `Avalible BCH( ${bchAvalible} ) is less than required( ${amount} ) `) + } else { + require(bchAvalible.gte(amount), `Avalible gas( ${bchAvalible} ) is less than gas limit( ${amount} ) `) + } +} + +function ensureContractNameValid(name) { + require(name && name.length >= 3 && name.length <= 32, 'Invalid contract name, length should be between 3 and 32 ') + require(name.match(/^[a-zA-Z]([-_a-zA-Z0-9]{3,32})+$/), 'Invalid contract name, please use letter, number or underscore ') +} + +function ensureGasLimitValid(gasLimit) { + require(gasLimit > 0 && gasLimit <= MAX_GAS_LIMIT, `gas limit must greater than 0 and less than ${MAX_GAS_LIMIT}`) +} + +function createContractTransfer(senderId, recipientId, currency, amount, trans, height) { + app.sdb.create(CONTRACT_TRANSFER_MODEL, { + id: Number(app.autoID.increment(CONTRACT_TRANSFER_ID_SEQUENCE)), + tid: trans.id, + height, + senderId, + recipientId, + currency, + amount: String(amount), + timestamp: trans.timestamp, + }) +} + +async function transfer(currency, transferAmount, senderId, recipientId, trans, height) { + const bigAmount = app.util.bignumber(transferAmount) + if (currency !== XAS_CURRENCY) { + const balance = app.balances.get(senderId, currency) + require(balance !== undefined && balance.gte(bigAmount), 'Insuffient balance') + + app.balances.transfer(currency, bigAmount.toString(), senderId, recipientId) + createContractTransfer(senderId, recipientId, currency, bigAmount.toString(), trans, height) + return + } + + const amount = Number.parseInt(bigAmount.toString(), 10) + const senderAccount = await app.sdb.load(ACCOUNT_MODEL, { address: senderId }) + require(senderAccount !== undefined, 'Sender account not found') + require(senderAccount.xas >= amount, 'Insuffient balance') + + app.sdb.increase(ACCOUNT_MODEL, { xas: -amount }, { address: senderId }) + recipientAccount = await app.sdb.load(ACCOUNT_MODEL, { address: recipientId }) + if (recipientAccount !== undefined) { + app.sdb.increase(ACCOUNT_MODEL, { xas: amount }, { address: recipientId }) + } else { + recipientAccount = app.sdb.create(ACCOUNT_MODEL, { + address: recipientId, + xas: amount, + name: null, + }) + } + createContractTransfer(senderId, recipientId, currency, amount, trans, height) +} + + +async function handleContractResult(senderId, contractId, contractAddr, callResult, trans, height) { + const { + success, error, gas, stateChangesHash, + } = callResult + + app.sdb.create(CONTRACT_RESULT_MODEL, { + tid: trans.id, + contractId, + success: success ? 1 : 0, + error, + gas, + stateChangesHash, + }) + + if (callResult.gas && callResult.gas > 0) { + await transfer(GAS_CURRENCY, callResult.gas, senderId, GAS_BUY_BACK_ADDRESS, trans, height) + } + + if (callResult.transfers && callResult.transfers.length > 0) { + for (const t of callResult.transfers) { + await transfer(t.currency, t.amount, contractAddr, t.recipientId, trans, height) + } + } +} + +/** + * Asch smart contract service code. All functions return transaction id by asch-core , + * you can get result by api/v2/contracts/?action=getResult&tid={transactionId} + */ +module.exports = { + /** + * Register contract, + * @param {number} gasLimit max gas avalible, 1000000 >= gasLimit >0 + * @param {string} name 32 >= name.length > 3 and name must be letter, number or _ + * @param {string} version contract engine version + * @param {string} desc desc.length <= 255 + * @param {string} code hex encoded source code + */ + async register(gasLimit, name, version, desc, code) { + ensureGasLimitValid(gasLimit) + ensureContractNameValid(name) + require(!desc || desc.length <= 255, 'Invalid description, can not be longer than 255') + require(!version || version.length <= 32, 'Invalid version, can not be longer than 32 ') + + await ensureBCHEnough(this.sender.address, gasLimit, true) + const contract = await app.sdb.load(CONTRACT_MODEL, { name }) + require(contract === undefined, `Contract '${name}' exists already`) + + const contractId = Number(app.autoID.increment(CONTRACT_ID_SEQUENCE)) + const context = makeContext(this.sender.address, this.trs, this.block) + const decodedCode = Buffer.from(code, 'hex').toString('utf8') + const registerResult = await app.contract.registerContract( + gasLimit, context, + contractId, name, decodedCode, + ) + const contractAddress = makeContractAddress(this.trs.id, this.sender.address) + handleContractResult( + this.sender.address, contractId, contractAddress, registerResult, + this.trs, this.block.height, + ) + + if (registerResult.success) { + app.sdb.create(CONTRACT_MODEL, { + id: contractId, + tid: this.trs.id, + name, + owner: this.sender.address, + address: contractAddress, + vmVersion: version, + desc, + code, + metadata: registerResult.metadata, + timestamp: this.trs.timestamp, + }) + } + }, + + /** + * Call method of a registered contract + * @param {number} gasLimit max gas avalible, 1000000 >= gasLimit >0 + * @param {string} name contract name + * @param {string} method method name of contract + * @param {Array} args method arguments + */ + async call(gasLimit, name, method, args) { + ensureGasLimitValid(gasLimit) + ensureContractNameValid(name) + require(method !== undefined && method !== null, 'method name can not be null or undefined') + require(Array.isArray(args), 'Invalid contract args, should be array') + + const contractInfo = await app.sdb.get(CONTRACT_MODEL, { name }) + require(contractInfo !== undefined, `Contract '${name}' not found`) + await ensureBCHEnough(this.sender.address, gasLimit, true) + + const context = makeContext(this.sender.address, this.trs, this.block) + const callResult = await app.contract.callContract(gasLimit, context, name, method, ...args) + + handleContractResult( + this.sender.address, contractInfo.id, contractInfo.address, callResult, + this.trs, this.block.height, + ) + }, + + /** + * Pay money to contract, behavior dependents on contract code. + * @param {number} gasLimit max gas avalible, 1000000 >= gasLimit >0 + * @param {string} nameOrAddress contract name or address + * @param {string|number} amount pay amout + * @param {string} currency currency + */ + async pay(gasLimit, nameOrAddress, amount, currency) { + ensureGasLimitValid(gasLimit) + const bigAmount = app.util.bignumber(amount) + require(bigAmount.gt(0), 'Invalid amount, should be greater than 0 ') + + const condition = app.util.address.isContractAddress(nameOrAddress) ? + { address: nameOrAddress } : { name: nameOrAddress } + + const contractInfo = await app.sdb.load(CONTRACT_MODEL, condition) + require(contractInfo !== undefined, `Contract name/address '${nameOrAddress}' not found`) + + const isBCH = (currency === GAS_CURRENCY) + const miniAmount = app.util.bignumber(gasLimit).plus(isBCH ? bigAmount : 0) + await ensureBCHEnough(this.sender.address, miniAmount, isBCH) + + await transfer( + currency, bigAmount.toString(), this.sender.address, contractInfo.address, + this.trs, this.block.height, + ) + + const context = makeContext(this.sender.address, this.trs, this.block) + const payResult = await app.contract.callContract( + gasLimit, context, contractInfo.name, + PAY_METHOD, bigAmount.toString(), currency, + ) + handleContractResult( + this.sender.address, contractInfo.id, contractInfo.address, payResult, + this.trs, this.block.height, + ) + }, +} + diff --git a/src/contract/exchange.js b/src/contract/exchange.js index 9a5f716..dcde981 100644 --- a/src/contract/exchange.js +++ b/src/contract/exchange.js @@ -53,8 +53,10 @@ module.exports = { const senderId = this.sender.address const bancor = await app.util.bancor .create(bancorInfo.money, bancorInfo.stock, bancorInfo.owner) - const simulateResult = await bancor.exchangeByTarget(sourceCurrency, - targetCurrency, targetAmount, false) + const simulateResult = await bancor.exchangeByTarget( + sourceCurrency, + targetCurrency, targetAmount, false, + ) // Check source account has sufficient balance to handle the exchange if (sourceCurrency === 'XAS') { if (simulateResult.sourceAmount.gt(app.util.bignumber(String(this.sender.xas)))) return 'Insufficient balance' diff --git a/src/contract/gateway.js b/src/contract/gateway.js index b629469..f2957e4 100644 --- a/src/contract/gateway.js +++ b/src/contract/gateway.js @@ -306,8 +306,10 @@ module.exports = { app.sdb.increase('Account', { xas: -needClaim }, { address: lockedAddr }) app.sdb.increase('Account', { xas: needClaim }, { address: senderId }) } - app.balances.transfer(gwCurrency[0].symbol, userAmount, - senderId, app.storeClaimedAddr) + app.balances.transfer( + gwCurrency[0].symbol, userAmount, + senderId, app.storeClaimedAddr, + ) } else { return 'Gateway was not revoked' } diff --git a/src/interface/contracts.js b/src/interface/contracts.js new file mode 100644 index 0000000..6fecf37 --- /dev/null +++ b/src/interface/contracts.js @@ -0,0 +1,126 @@ +const assert = require('assert') + +const CONTRACT_MODEL = 'Contract' +const CONTRACT_BASIC_FIELDS = ['id', 'name', 'tid', 'address', 'owner', 'vmVersion', 'desc', 'timestamp'] +const CONTRACT_RESULT_MODEL = 'ContractResult' + +function parseSort(orderBy) { + const sort = {} + const [orderField, sortOrder] = orderBy.split(':') + if (orderField !== undefined && sortOrder !== undefined) { + sort[orderField] = sortOrder.toUpperCase() + } + return sort +} + +function makeCondition(params) { + const result = {} + Object.keys(params).forEach((k) => { + if (params[k] !== undefined) result[k] = params[k] + }) + return result +} + +/** + * Query contract call result + * @param tid ?action=getResult&tid='xxxx' + * @returns query result { result : { tid, contractId, success, gas, error, stateChangesHash } } + */ +async function handleGetResult(req) { + const tid = req.query.tid + assert(tid !== undefined && tid !== null, 'Invalid param \'tid\', can not be null or undefined') + const results = await app.sdb.find(CONTRACT_RESULT_MODEL, { tid }) + if (results.length === 0) { + throw new Error(`Result not found (tid = '${tid}')`) + } + const ret = results[0] + return { + result: { + success: ret.success > 0, + gas: ret.gas || 0, + error: ret.error || '', + stateChangesHash: ret.stateChangesHash || '', + }, + } +} + +async function handleActionRequest(req) { + const action = req.query.action + if (action === 'getResult') { + const result = await handleGetResult(req) + return result + } + // other actions ... + throw new Error(`Invalid action, ${action}`) +} + + +module.exports = (router) => { + /** + * Query contracts + * @param condition owner, address, name, orderBy = id:ASC, limit = 20, offset = 0, + * orderBy = (timestamp | id | owner):(ASC|DESC) + * @returns query result { count, + * contracts : [ { id, name, tid, address, owner, vmVersion, desc, timestamp } ] } + */ + router.get('/', async (req) => { + if (req.query.action) { + const result = await handleActionRequest(req) + return result + } + + const offset = req.query.offset ? Math.max(0, Number(req.query.offset)) : 0 + const limit = req.query.limit ? Math.min(100, Number(req.query.limit)) : 20 + const orderBy = req.query.orderBy ? req.query.orderBy : 'id:ASC' + + const sortOrder = parseSort(orderBy) + const { name, owner, address } = req.query + const condition = makeCondition({ name, owner, address }) + const fields = CONTRACT_BASIC_FIELDS + + const count = await app.sdb.count(CONTRACT_MODEL, condition) + const range = { limit, offset } + const contracts = await app.sdb.find(CONTRACT_MODEL, condition, range, sortOrder, fields) + + return { count, contracts } + }) + + + /** + * Get contract details + * @param name name of contract + * @returns contract detail { contract : { id, name, tid, address, owner, vmVersion, + * desc, timestamp, metadata } } + */ + router.get('/:name', async (req) => { + const name = req.params.name + const contracts = await app.sdb.find(CONTRACT_MODEL, { name }) + if (!contracts || contracts.length === 0) throw new Error('Not found') + return { contract: contracts[0] } + }) + + /** + * Get state of contract + * @param name name of contract + * @param stateName name of mapping state + * @param key key of mapping state + * @returns state value + */ + router.get('/:name/states/:stateName/:key', async (req) => { + const { name, stateName, key } = req.params + const state = await app.contract.queryState(name, stateName, key) + return { state } + }) + + /** + * Get state of contract + * @param name name of contract + * @param stateName state name + * @returns state value + */ + router.get('/:name/states/:stateName', async (req) => { + const { name, stateName } = req.params + const state = await app.contract.queryState(name, stateName) + return { state } + }) +} diff --git a/src/interface/markets.js b/src/interface/markets.js index 72afa6f..bfaba40 100644 --- a/src/interface/markets.js +++ b/src/interface/markets.js @@ -154,24 +154,20 @@ async function getCurrencies(req) { result.push({ assetName: 'XAS', precision: 8 }) assets = await app.sdb.findAll('Asset', { limit, offset }) assets.forEach((element) => { - result.push( - { - assetName: element.name, - precision: element.precision, - maxSupply: element.maximum, - }, - ) + result.push({ + assetName: element.name, + precision: element.precision, + maxSupply: element.maximum, + }) }) gwCurrencies = await app.sdb.findAll('GatewayCurrency', { limit, offset }) gwCurrencies.forEach((element) => { - result.push( - { - assetName: element.symbol, - precision: element.precision, - maxSupply: element.quantity, - }, - ) + result.push({ + assetName: element.symbol, + precision: element.precision, + maxSupply: element.quantity, + }) }) return result } diff --git a/src/model/contract-result.js b/src/model/contract-result.js new file mode 100644 index 0000000..486aae4 --- /dev/null +++ b/src/model/contract-result.js @@ -0,0 +1,12 @@ +module.exports = { + table: 'contract_results', + tableFields: [ + { name: 'tid', type: 'String', length: 64, primary_key: true }, + { name: 'contractId', type: 'Number', not_null: true, index: true }, + { name: 'success', type: 'Number', not_null: true }, + { name: 'error', type: 'String', length: 128 }, + { name: 'gas', type: 'Number' }, + { name: 'stateChangesHash', type: 'String', length: 64 } + ] + } + \ No newline at end of file diff --git a/src/model/contract-transfer.js b/src/model/contract-transfer.js new file mode 100644 index 0000000..3d94bf0 --- /dev/null +++ b/src/model/contract-transfer.js @@ -0,0 +1,14 @@ +module.exports = { + table: 'contract_transfers', + tableFields: [ + { name: 'id', type: 'Number', not_null: true, primary_key: true }, + { name: 'tid', type: 'String', length: 64, not_null: true, index: true }, + { name: 'height', type: 'Number', not_null: true, index: true }, + { name: 'senderId', type: 'String', length: 50, not_null: true, index: true }, + { name: 'recipientId', type: 'String', length: 50, not_null: true, index: true }, + { name: 'currency', type: 'String', length: 30, not_null: true, index: true }, + { name: 'amount', type: 'String', length: 50, not_null: true }, + { name: 'timestamp', type: 'Number', index: true } + ] + } + \ No newline at end of file diff --git a/src/model/contract.js b/src/model/contract.js new file mode 100644 index 0000000..1702281 --- /dev/null +++ b/src/model/contract.js @@ -0,0 +1,16 @@ +module.exports = { + table: 'contracts', + tableFields: [ + { name: 'id', type: 'Number', primary_key: true }, + { name: 'tid', type: 'String', length: 64, not_null: true, unique: true }, + { name: 'name', type: 'String', length: 32, not_null: true, unique: true }, + { name: 'address', type: 'String', length: 50, unique: true }, + { name: 'owner', type: 'String', length: 50, not_null: true, index: true }, + { name: 'desc', type: 'String', length: 255 }, + { name: 'vmVersion', type: 'String', length: 32 }, + { name: 'code', type: 'Text', not_null: true }, + { name: 'metadata', type: 'Json', not_null: true }, + { name: 'timestamp', type: 'Number', not_null: true } + ] + } + \ No newline at end of file