@aeternity/aepp-sdk
Version:
SDK for the æternity blockchain
422 lines (389 loc) • 14.5 kB
JavaScript
import BigNumber from 'bignumber.js'
import { decode as rlpDecode, encode as rlpEncode } from 'rlp'
import { AE_AMOUNT_FORMATS, formatAmount } from '../../utils/amount-formatter'
import { assertedType } from '../../utils/crypto'
import {
DEFAULT_FEE,
FIELD_TYPES,
OBJECT_ID_TX_TYPE,
PREFIX_ID_TAG,
TX_DESERIALIZATION_SCHEMA,
TX_FEE_BASE_GAS,
TX_FEE_OTHER_GAS,
TX_SERIALIZATION_SCHEMA,
TX_TYPE,
VALIDATION_MESSAGE,
VSN
} from './schema'
import {
readInt,
readId,
readPointers,
writeId,
writeInt,
buildPointers,
encode,
decode,
buildHash
} from './helpers'
import { toBytes } from '../../utils/bytes'
import MPTree from '../../utils/mptree'
/**
* JavaScript-based Transaction builder
* @module @aeternity/aepp-sdk/es/tx/builder
* @export TxBuilder
* @example import { TxBuilder } from '@aeternity/aepp-sdk'
*/
const ORACLE_TTL_TYPES = {
delta: 'delta',
block: 'block'
}
// SERIALIZE AND DESERIALIZE PART
function deserializeField (value, type, prefix) {
if (!value) return ''
switch (type) {
case FIELD_TYPES.ctVersion: {
const [vm, , abi] = value
return { vmVersion: readInt(Buffer.from([vm])), abiVersion: readInt(Buffer.from([abi])) }
}
case FIELD_TYPES.amount:
return readInt(value)
case FIELD_TYPES.int:
return readInt(value)
case FIELD_TYPES.id:
return readId(value)
case FIELD_TYPES.ids:
return value.map(readId)
case FIELD_TYPES.bool:
return value[0] === 1
case FIELD_TYPES.binary:
return encode(value, prefix)
case FIELD_TYPES.stateTree:
return encode(value, 'ss')
case FIELD_TYPES.string:
return value.toString()
case FIELD_TYPES.payload:
return encode(value, 'ba')
case FIELD_TYPES.pointers:
return readPointers(value)
case FIELD_TYPES.rlpBinary:
return unpackTx(value, true)
case FIELD_TYPES.rlpBinaries:
return value.map(v => unpackTx(v, true))
case FIELD_TYPES.rawBinary:
return value
case FIELD_TYPES.hex:
return value.toString('hex')
case FIELD_TYPES.offChainUpdates:
return value.map(v => unpackTx(v, true))
case FIELD_TYPES.callStack:
// TODO: fix this
return [readInt(value)]
case FIELD_TYPES.mptrees:
return value.map(t => new MPTree(t))
case FIELD_TYPES.callReturnType:
switch (readInt(value)) {
case '0': return 'ok'
case '1': return 'error'
case '2': return 'revert'
default: return value
}
case FIELD_TYPES.sophiaCodeTypeInfo:
return value
.reduce(
(acc, [funHash, fnName, argType, outType]) =>
({ ...acc, [fnName.toString()]: { funHash, argType, outType } }),
{}
)
default:
return value
}
}
function serializeField (value, type, prefix) {
switch (type) {
case FIELD_TYPES.amount:
case FIELD_TYPES.int:
return writeInt(value)
case FIELD_TYPES.id:
return writeId(value)
case FIELD_TYPES.ids:
return value.map(writeId)
case FIELD_TYPES.bool:
return Buffer.from([value ? 1 : 0])
case FIELD_TYPES.binary:
return decode(value, prefix)
case FIELD_TYPES.stateTree:
return decode(value, 'ss')
case FIELD_TYPES.hex:
return Buffer.from(value, 'hex')
case FIELD_TYPES.signatures:
return value.map(Buffer.from)
case FIELD_TYPES.payload:
return typeof value === 'string' && value.split('_')[0] === 'ba'
? decode(value, 'ba')
: toBytes(value)
case FIELD_TYPES.string:
return toBytes(value)
case FIELD_TYPES.pointers:
return buildPointers(value)
case FIELD_TYPES.mptrees:
return value.map(t => t.serialize())
case FIELD_TYPES.ctVersion:
return Buffer.from([...toBytes(value.vmVersion), 0, ...toBytes(value.abiVersion)])
case FIELD_TYPES.callReturnType:
switch (value) {
case 'ok': return writeInt(0)
case 'error': return writeInt(1)
case 'revert': return writeInt(2)
default: return value
}
default:
return value
}
}
function validateField (value, key, type, prefix) {
const assert = (valid, params) => valid ? {} : { [key]: VALIDATION_MESSAGE[type](params) }
// All fields are required
if (value === undefined || value === null) return { [key]: 'Field is required' }
// Validate type of value
switch (type) {
case FIELD_TYPES.amount:
case FIELD_TYPES.int: {
const isMinusValue = (!isNaN(value) || BigNumber.isBigNumber(value)) && BigNumber(value).lt(0)
return assert((!isNaN(value) || BigNumber.isBigNumber(value)) && BigNumber(value).gte(0), { value, isMinusValue })
}
case FIELD_TYPES.id:
if (Array.isArray(prefix)) {
const p = prefix.find(p => p === value.split('_')[0])
return assert(p && PREFIX_ID_TAG[value.split('_')[0]], { value, prefix })
}
return assert(assertedType(value, prefix) && PREFIX_ID_TAG[value.split('_')[0]] && value.split('_')[0] === prefix, { value, prefix })
case FIELD_TYPES.binary:
return assert(value.split('_')[0] === prefix, { prefix, value })
case FIELD_TYPES.string:
return assert(true)
case FIELD_TYPES.ctVersion:
return assert(typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'abiVersion') && Object.prototype.hasOwnProperty.call(value, 'vmVersion'))
case FIELD_TYPES.pointers:
return assert(Array.isArray(value) && !value.find(e => e !== Object(e)), { value })
default:
return {}
}
}
function transformParams (params, schema, { denomination } = {}) {
params = schema
.filter(([_, t]) => t === FIELD_TYPES.amount)
.reduce((acc, [key]) => ({ ...params, [key]: formatAmount(params[key], { denomination }) }), params)
const schemaKeys = schema.map(([k]) => k)
return Object
.entries(params)
.reduce(
(acc, [key, value]) => {
if (schemaKeys.includes(key)) acc[key] = value
if (['oracleTtl', 'queryTtl', 'responseTtl'].includes(key)) {
acc[`${key}Type`] = value.type === ORACLE_TTL_TYPES.delta ? 0 : 1
acc[`${key}Value`] = value.value
}
return acc
},
{}
)
}
// INTERFACE
function getOracleRelativeTtl (params, txType) {
const ttlKey = {
[TX_TYPE.oracleRegister]: 'oracleTtl',
[TX_TYPE.oracleExtend]: 'oracleTtl',
[TX_TYPE.oracleQuery]: 'queryTtl',
[TX_TYPE.oracleResponse]: 'responseTtl'
}[txType]
if (params[ttlKey] || params[`${ttlKey}Value`]) {
return params[`${ttlKey}Value`] || params[ttlKey].value
}
return 1
}
/**
* Calculate min fee
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder/index
* @rtype (txType, { gas = 0, params }) => String
* @param {String} txType - Transaction type
* @param {Options} options - Options object
* @param {String|Number} options.gas - Gas amount
* @param {Object} options.params - Tx params
* @return {String|Number}
* @example calculateMinFee('spendTx', { gas, params })
*/
export function calculateMinFee (txType, { gas = 0, params, vsn }) {
const multiplier = BigNumber(1e9) // 10^9 GAS_PRICE
if (!params) return BigNumber(DEFAULT_FEE).times(multiplier).toString(10)
let actualFee = buildFee(txType, { params: { ...params, fee: 0 }, multiplier, gas, vsn })
let expected = BigNumber(0)
while (!actualFee.eq(expected)) {
actualFee = buildFee(txType, { params: { ...params, fee: actualFee }, multiplier, gas, vsn })
expected = actualFee
}
return expected.toString(10)
}
/**
* Calculate fee based on tx type and params
* @param txType
* @param params
* @param gas
* @param multiplier
* @param vsn
* @return {BigNumber}
*/
function buildFee (txType, { params, gas = 0, multiplier, vsn }) {
const { rlpEncoded: txWithOutFee } = buildTx({ ...params }, txType, { vsn })
const txSize = txWithOutFee.length
return TX_FEE_BASE_GAS(txType)
.plus(TX_FEE_OTHER_GAS(txType)({ txSize, relativeTtl: getOracleRelativeTtl(params, txType) }))
.times(multiplier)
}
/**
* Calculate fee
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @rtype (fee, txType, gas = 0) => String
* @param {String|Number} fee - fee
* @param {String} txType - Transaction type
* @param {Options} options - Options object
* @param {String|Number} options.gas - Gas amount
* @param {Object} options.params - Tx params
* @return {String|Number}
* @example calculateFee(null, 'spendTx', { gas, params })
*/
export function calculateFee (fee = 0, txType, { gas = 0, params, showWarning = true, vsn } = {}) {
if (!params && showWarning) console.warn(`Can't build transaction fee, we will use DEFAULT_FEE(${DEFAULT_FEE})`)
return fee || calculateMinFee(txType, { params, gas, vsn })
}
/**
* Validate transaction params
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @param {Object} params Object with tx params
* @param {Array} schema Transaction schema
* @param {Array} excludeKeys Array of keys to exclude for validation
* @return {Object} Object with validation errors
*/
export function validateParams (params, schema, { excludeKeys = [] }) {
return schema
.filter(([key]) => !excludeKeys.includes(key) && key !== 'payload')
.reduce(
(acc, [key, type, prefix]) => Object.assign(acc, validateField(params[key], key, type, prefix)),
{}
)
}
/**
* Build binary transaction
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @param {Object} params Object with tx params
* @param {Array} schema Transaction schema
* @param {Object} [options={}] options
* @param {Array} [options.excludeKeys=[]] excludeKeys Array of keys to exclude for validation and build
* @param {String} [options.denomination='aettos'] denomination Denomination of amounts (default: aettos)
* @throws {Error} Validation error
* @return {Array} Array with binary fields of transaction
*/
export function buildRawTx (params, schema, { excludeKeys = [], denomination = AE_AMOUNT_FORMATS.AETTOS } = {}) {
const filteredSchema = schema.filter(([key]) => !excludeKeys.includes(key))
// Transform `amount` type fields to `aettos`
params = transformParams(params, filteredSchema, { denomination })
// Validation
const valid = validateParams(params, schema, { excludeKeys })
if (Object.keys(valid).length) {
throw new Error('Transaction build error. ' + JSON.stringify(valid))
}
return filteredSchema
.map(([key, fieldType, prefix]) => serializeField(params[key], fieldType, prefix))
}
/**
* Unpack binary transaction
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @param {Array} binary Array with binary transaction field's
* @param {Array} schema Transaction schema
* @return {Object} Object with transaction field's
*/
export function unpackRawTx (binary, schema) {
return schema
.reduce(
(
acc,
[key, fieldType, prefix],
index
) => Object.assign(acc, { [key]: deserializeField(binary[index], fieldType, prefix) }),
{}
)
}
/**
* Get transaction serialization/deserialization schema
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @param {{ vsn: String, objId: Number, type: String }}
* @throws {Error} Schema not found error
* @return {Object} Schema
*/
const getSchema = ({ vsn, objId, type }) => {
const isDeserialize = !!objId
const firstKey = isDeserialize ? objId : type
const schema = isDeserialize ? TX_DESERIALIZATION_SCHEMA : TX_SERIALIZATION_SCHEMA
if (!schema[firstKey]) {
throw new Error(`Transaction ${isDeserialize ? 'deserialization' : 'serialization'} not implemented for ${isDeserialize ? 'tag ' + objId : type}`)
}
if (!schema[firstKey][vsn]) {
throw new Error(`Transaction ${isDeserialize ? 'deserialization' : 'serialization'} not implemented for ${isDeserialize ? 'tag ' + objId : type} version ${vsn}`)
}
return schema[firstKey][vsn]
}
/**
* Build transaction hash
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @param {Object} params Object with tx params
* @param {String} type Transaction type
* @param {Object} [options={}] options
* @param {Object} [options.excludeKeys] excludeKeys Array of keys to exclude for validation and build
* @param {String} [options.prefix] Prefix of transaction
* @throws {Error} Validation error
* @return {Object} { tx, rlpEncoded, binary } Object with tx -> Base64Check transaction hash with 'tx_' prefix, rlp encoded transaction and binary transaction
*/
export function buildTx (params, type, { excludeKeys = [], prefix = 'tx', vsn = VSN, denomination = AE_AMOUNT_FORMATS.AETTOS } = {}) {
const [schema, tag] = getSchema({ type, vsn })
const binary = buildRawTx({ ...params, VSN: vsn, tag }, schema, { excludeKeys, denomination: params.denomination || denomination }).filter(e => e !== undefined)
const rlpEncoded = rlpEncode(binary)
const tx = encode(rlpEncoded, prefix)
return { tx, rlpEncoded, binary, txObject: unpackRawTx(binary, schema) }
}
/**
* Unpack transaction hash
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @param {String|Buffer} encodedTx String or RLP encoded transaction array (if fromRlpBinary flag is true)
* @param {Boolean} fromRlpBinary Unpack from RLP encoded transaction (default: false)
* @param {String} prefix - Prefix of data
* @return {Object} { tx, rlpEncoded, binary } Object with tx -> Object with transaction param's, rlp encoded transaction and binary transaction
*/
export function unpackTx (encodedTx, fromRlpBinary = false, prefix = 'tx') {
const rlpEncoded = fromRlpBinary ? encodedTx : decode(encodedTx, prefix)
const binary = rlpDecode(rlpEncoded)
const objId = readInt(binary[0])
const vsn = readInt(binary[1])
const [schema] = getSchema({ objId, vsn })
return { txType: OBJECT_ID_TX_TYPE[objId], tx: unpackRawTx(binary, schema), rlpEncoded, binary }
}
/**
* Build a transaction hash
* @function
* @alias module:@aeternity/aepp-sdk/es/tx/builder
* @param {String | Buffer} rawTx base64 or rlp encoded transaction
* @param {Object} options
* @param {Boolean} options.raw
* @return {String} Transaction hash
*/
export function buildTxHash (rawTx, options) {
if (typeof rawTx === 'string' && rawTx.indexOf('tx_') !== -1) return buildHash('th', unpackTx(rawTx).rlpEncoded, options)
return buildHash('th', rawTx, options)
}
export default { calculateMinFee, calculateFee, unpackTx, unpackRawTx, buildTx, buildRawTx, validateParams, buildTxHash }