Home Reference Source

src/conversion_rates_contract.js

import Web3 from 'web3'
import BigNumber from 'bignumber.js'

import BaseContract from './base_contract.js'
import conversionRatesABI from '../abi/ConversionRatesContract.abi.json'
import { validateAddress } from './validate.js'
import { assertOperator, assertAdmin } from './permission_assert.js'

/**
 * CompactData is used to save gas on get, set rates operations.
 * Instead of sending the whole buy, sell rates every time, only the different
 * is sent if possible.
 * The compact rate is calculated by following formula:
 *
 *      compact = ((rate / base) - 1) * 1000
 */
export class CompactData {
  get isBaseChanged () {
    return this._isBaseChanged
  }

  set isBaseChanged (value) {
    this._isBaseChanged = value
  }

  get compact () {
    return this._compact
  }

  set compact (value) {
    this._compact = value
  }

  get base () {
    return this._base
  }

  set base (value) {
    this._base = value
  }

  /**
   * Create a new CompactData instance.
   * @param {BigNumber} rate - the total rate
   * @param {BigNumber} base - the base to generate compact data
   */
  constructor (rate, base) {
    // const compactData = buildCompactData(rate, base)
    const minInt8 = -128
    const maxInt8 = 127

    rate = new BigNumber(rate)
    base = new BigNumber(base)

    let compact

    if (base.isEqualTo(0)) {
      base = rate
      this.isBaseChanged = true
      compact = new BigNumber(0)
    } else {
      compact = rate
        .dividedBy(base)
        .minus(new BigNumber(1))
        .multipliedBy(1000.0)
        .integerValue()

      // compact data is fit in a byte
      if (
        compact.isGreaterThanOrEqualTo(minInt8) &&
        compact.isLessThanOrEqualTo(maxInt8)
      ) {
        // overflowed, convert from int8 to byte so
        // * -1 --> 255
        // * -128 --> 128
        if (compact.isLessThan(0)) {
          compact = new BigNumber(2 ** 8).plus(compact)
        }
      } else {
        base = rate
        this.isBaseChanged = true
        compact = new BigNumber(0)
      }
    }

    this._base = base
    this._compact = compact
  }
}

/**
 * Build the compact data input.
 * In ConversionRates contract, the compact data is stored in two dimensions
 * array with location:
 *   - bulkIndex
 *   - indexInBulk
 *
 * When setting compact data, user needs to submit the whole bulk along with
 * its index.
 *
 * @param newBuys - buy compact data
 * @param newSells - sell compact data
 * @param indices - map of address to its bulk index
 * @return {{buyResults: Array, sellResults: Array, indexResults: Array}}
 */
export const buildCompactBulk = (newBuys, newSells, indices) => {
  let buyResults = []
  let sellResults = []
  let indexResults = []
  let buyBulks = {}
  let sellBulks = {}

  for (let addr in newBuys) {
    if (newBuys.hasOwnProperty(addr)) {
      const loc = indices[addr]
      if (!(loc.bulkIndex in buyBulks)) {
        buyBulks[loc.bulkIndex] = new Array(14).fill(0)
        sellBulks[loc.bulkIndex] = new Array(14).fill(0)
      }
      buyBulks[loc.bulkIndex][loc.indexInBulk] = newBuys[addr]
      sellBulks[loc.bulkIndex][loc.indexInBulk] = newSells[addr]
    }
  }

  for (let i in buyBulks) {
    if (buyBulks.hasOwnProperty(i)) {
      buyResults.push(buyBulks[i])
      sellResults.push(sellBulks[i])
      indexResults.push(i)
    }
  }
  return { buyResults, sellResults, indexResults }
}

/**
 * TokenControlInfo is the configurations of a ERC20 token.
 */
export class TokenControlInfo {
  get maxTotalImbalance () {
    return this._maxTotalImbalance
  }

  set maxTotalImbalance (value) {
    this._maxTotalImbalance = value
  }

  get maxPerBlockImbalance () {
    return this._maxPerBlockImbalance
  }

  set maxPerBlockImbalance (value) {
    this._maxPerBlockImbalance = value
  }

  get minimalRecordResolution () {
    return this._minimalRecordResolution
  }

  set minimalRecordResolution (value) {
    this._minimalRecordResolution = value
  }

  /**
   * Create a new TokenControlInfo instance.
   * @param minimalRecordResolution {uint} - minimum denominator in token wei that can be changed
   * @param maxPerBlockImbalance {uint} - maximum wei amount of net absolute (+/-) change for a token in an ethereum
   * block
   * @param maxTotalImbalance {uint} - wei amount of the maximum net token change allowable that happens between 2
   * price updates
   */
  constructor (
    minimalRecordResolution,
    maxPerBlockImbalance,
    maxTotalImbalance
  ) {
    this._minimalRecordResolution = minimalRecordResolution
    this._maxPerBlockImbalance = maxPerBlockImbalance
    this._maxTotalImbalance = maxTotalImbalance
  }
}

// StepFunctionDataPoint is the data point of a step function.
export class StepFunctionDataPoint {
  get y () {
    return this._y
  }

  set y (value) {
    this._y = value
  }

  get x () {
    return this._x
  }

  set x (value) {
    this._x = value
  }

  /**
   * Create a new StepFunctionDataPoint.
   * @param x {int} - buy step in wei amount
   * @param y {int} - impact on buy rate in basis points (bps). 1 bps = 0.01%
   */
  constructor (x, y) {
    this._x = x
    this._y = y
  }
}

/**
 * RateSetting represents the buy sell rates of a ERC20 token.
 */
export class RateSetting {
  get sell () {
    return this._sell
  }

  set sell (value) {
    this._sell = value
  }

  get buy () {
    return this._buy
  }

  set buy (value) {
    this._buy = value
  }

  get address () {
    return this._address
  }

  set address (value) {
    this._address = value
  }

  /**
   * Create a new RateSetting instance.
   * @param {string} address - ERC20 token address
   * @param {number} buy - buy rate per ETH
   * @param {number} sell - sell rate per ETH
   */
  constructor (address, buy, sell) {
    validateAddress(address)

    this._address = address
    this._buy = buy
    this._sell = sell
    // this._buy = Web3.utils.padLeft(Web3.utils.numberToHex(buy), 14)
    // this._sell = Web3.utils.padLeft(Web3.utils.numberToHex(sell), 14)
  }
}

/**
 * CompactDataLocation is the location of compact data of a token in
 * ConversionRates contract. When adding a new token, ConversionRatesContract
 * allocated a fixed location in compact data array for it, this can't be
 * changed.
 */
export class CompactDataLocation {
  get indexInBulk () {
    return this._indexInBulk
  }

  set indexInBulk (value) {
    this._indexInBulk = value
  }

  get bulkIndex () {
    return this._bulkIndex
  }

  set bulkIndex (value) {
    this._bulkIndex = value
  }

  constructor (bulkIndex, indexInBulk) {
    this._bulkIndex = bulkIndex
    this._indexInBulk = indexInBulk
  }
}

/**
 * ConversionRatesContract represents the KyberNetwork conversion rates smart
 * contract.
 */
export default class ConversionRatesContract extends BaseContract {
  /**
   * Create new ConversionRatesContract instance.
   * @param {object} provider - Web3 provider
   * @param {string} address - address of smart contract.
   */
  constructor (web3, address) {
    super(web3, address)
    this.web3 = web3
    this.contract = new this.web3.eth.Contract(conversionRatesABI, address)

    /**
     * getTokenIndices returns the index of given Token to use in setCompact
     * data call.
     * @param {string} token - ERC 20 token address
     * @return {number} - index ot compact data
     */
    this.getTokenIndices = token => {
      let tokenIndices = {}

      return (async () => {
        validateAddress(token)

        if (token in tokenIndices) {
          return tokenIndices[token]
        }

        let results
        try {
          results = await this.contract.methods.getCompactData(token).call()
        } catch (err) {
          console.log(
            `failed to query token ${token} for compact data, error: ${err}`
          )
          return
        }
        tokenIndices[token] = new CompactDataLocation(results[0], results[1])
        return tokenIndices[token]
      })()
    }
  }

  /**
   * Add a ERC20 token and its pricing configurations to reserve contract and
   * enable it for trading.
   * @param {object} adminAccount - admin account address
   * @param {string} token - ERC20 token address
   * @param {TokenControlInfo} tokenControlInfo - https://developer.kyber.network/docs/VolumeImbalanceRecorder#settokencontrolinfo
   * @param {number} gasPrice (optional) - the gasPrice desired for the tx
   */

  async addToken (adminAccount, token, tokenControlInfo, gasPrice) {
    validateAddress(token)
    await assertAdmin(this, adminAccount)
    let addTokenTx = this.contract.methods.addToken(token)
    await addTokenTx.send({
      from: adminAccount,
      gas: await addTokenTx.estimateGas({ from: adminAccount }),
      gasPrice: gasPrice
    })

    console.log("Token Added...")

    var controlInfoTx = this.contract.methods.setTokenControlInfo(
      token,
      tokenControlInfo.minimalRecordResolution,
      tokenControlInfo.maxPerBlockImbalance,
      tokenControlInfo.maxTotalImbalance
    )
    await controlInfoTx.send({
      from: adminAccount,
      gas: await controlInfoTx.estimateGas({ from: adminAccount }),
      gasPrice: gasPrice
    })

    console.log("Token Control Information Updated...")

    var enableTokenTx = this.contract.methods.enableTokenTrade(token)
    await enableTokenTx.send({
      from: adminAccount,
      gas: await enableTokenTx.estimateGas({ from: adminAccount }),
      gasPrice: gasPrice
    })

    console.log("Token Enabled...")

    return this.getTokenIndices(token)
  }
/**
   * Add a ERC20 token and its pricing configurations to reserve contract and
   * enable it for trading.
   * @param {object} adminAccount - admin account address
   * @param {string} token - ERC20 token address
   * @param {TokenControlInfo} tokenControlInfo - https://developer.kyber.network/docs/VolumeImbalanceRecorder#settokencontrolinfo
   * @param {number} gasPrice (optional) - the gasPrice desired for the tx
   */
  async updateTokenControlInfo (adminAccount, token, tokenControlInfo, gasPrice) {
    validateAddress(token)
    await assertAdmin(this, adminAccount)

    var controlInfoTx = this.contract.methods.setTokenControlInfo(
      token,
      tokenControlInfo.minimalRecordResolution,
      tokenControlInfo.maxPerBlockImbalance,
      tokenControlInfo.maxTotalImbalance
    )
    await controlInfoTx.send({
      from: adminAccount,
      gas: await controlInfoTx.estimateGas({ from: adminAccount }),
      gasPrice: gasPrice
    })

    console.log("Token Control Information Updated...")

    return this.getTokenIndices(token)
  }


  /**
   * Set adjustments for tokens' buy and sell rates depending on the net traded
   * amounts. Only operator can invoke.
   * @param {object} operatorAddress - address of the operator account
   * @param {string} token - ERC20 token address
   * @param {StepFunctionDataPoint[]} buy - array of buy step function configurations
   * @param {StepFunctionDataPoint[]} sell - array of sell step function configurations
   * @param {number} [gasPrice=undefined] - the gasPrice desired for the tx
   */
  async setImbalanceStepFunction (
    operatorAddress,
    token,
    buy,
    sell,
    gasPrice = undefined
  ) {
    validateAddress(token)
    await assertOperator(this, operatorAddress)
    const xBuy = buy.map(val => val.x)
    const yBuy = buy.map(val => val.y)
    const xSell = sell.map(val => val.x)
    const ySell = sell.map(val => val.y)
    if (yBuy > 0) {
      console.warn(
        `yBuy ${yBuy} is positive, which is contradicted to the logic of setImbalanceStepFunction`
      )
    }
    if (ySell > 0) {
      console.warn(
        `ySell ${ySell} is positive, which is contradicted to the logic of setImbalanceStepFunction`
      )
    }
    let tx = this.contract.methods.setImbalanceStepFunction(
      token,
      xBuy,
      yBuy,
      xSell,
      ySell
    )
    return tx.send({
      from: operatorAddress,
      gas: await tx.estimateGas({ from: operatorAddress }),
      gasPrice: gasPrice
    })
  }

  /**
   * Set adjustments for tokens' buy and sell rates depending on the size of a
   * buy / sell order. Only operator can invoke.
   * @param {object} operatorAddress - address of the operator account
   * @param {string} token - ERC20 token address
   * @param {StepFunctionDataPoint[]} buy - array of buy step function configurations
   * @param {StepFunctionDataPoint[]} sell - array of sell step function configurations
   * @param {number} gasPrice (optional) - the gasPrice desired for the tx
   */
  async setQtyStepFunction (operatorAddress, token, buy, sell, gasPrice) {
    validateAddress(token)
    await assertOperator(this, operatorAddress)
    const xBuy = buy.map(val => val.x)
    const yBuy = buy.map(val => val.y)
    const xSell = sell.map(val => val.x)
    const ySell = sell.map(val => val.y)
    if (yBuy > 0) {
      console.warn(
        `yBuy ${yBuy} is positive, which is contradicted to the logic of setQtyStepFunction`
      )
    }
    if (ySell > 0) {
      console.warn(
        `ySell ${ySell} is positive, which is contradicted to the logic of setQtyStepFunction`
      )
    }
    let tx = this.contract.methods.setQtyStepFunction(
      token,
      xBuy,
      yBuy,
      xSell,
      ySell
    )

    return tx.send({
      from: operatorAddress,
      gas: await tx.estimateGas({ from: operatorAddress }),
      gasPrice: gasPrice
    })
  }

  /**
   * Return the buying ETH based rate. The rate might be vary with
   * different quantity.
   * @param {string} token - token address
   * @param {number} qty - quantity of token
   * @param {number} [currentBlockNumber=0] - current block number, default to
   * use latest known block number.
   * @return {number} - buy rate
   */
  getBuyRates (token, qty, currentBlockNumber = 0) {
    return this.contract.methods
      .getRate(token, currentBlockNumber, true, qty)
      .call()
  }

  /**
   * Return the buying ETH based rate. The rate might be vary with
   * different quantity.
   * @param {string} token - token address
   * @param {number} qty - quantity of token
   * @param {number} [currentBlockNumber=0] - current block number
   * known block number.
   */
  getSellRates (token, qty, currentBlockNumber = 0) {
    return this.contract.methods
      .getRate(token, currentBlockNumber, false, qty)
      .call()
  }

  /**
   * Set the buying rate for given token.
   * @param {object} operatorAddress - address of the operator account
   * @param {RateSetting[]} rates - token address
   * @param {number} [currentBlockNumber=0] - current block number
   * @param {number} gasPrice (optional) - the gasPrice desired for the tx
   */
  async setRate (operatorAddress, rates, currentBlockNumber = 0, gasPrice) {
    await assertOperator(this, operatorAddress)
    const indices = await rates.reduce(async (acc, val) => {
      const accumulator = await acc.then()
      accumulator[val.address] = await this.getTokenIndices(val.address)
      return Promise.resolve(accumulator)
    }, Promise.resolve({}))

    const data = await rates.reduce(
      async (acc, val) => {
        const accumulator = await acc.then()

        const currentBaseBuy = await this.contract.methods
          .getBasicRate(val.address, true)
          .call()

        const buyCompactData = new CompactData(val.buy, currentBaseBuy)

        const currentBaseSell = await this.contract.methods
          .getBasicRate(val.address, false)
          .call()
        const sellCompactData = new CompactData(val.sell, currentBaseSell)

        if (buyCompactData.isBaseChanged || sellCompactData.isBaseChanged) {
          accumulator.tokens.push(val.address)
          accumulator.baseBuys.push(buyCompactData.base.toString())
          accumulator.baseSells.push(sellCompactData.base.toString())
        }
        const buyCompact = buyCompactData.compact.toString()
        accumulator.compactBuys[val.address] = buyCompact
        const sellCompact = sellCompactData.compact.toString()
        accumulator.compactSells[val.address] = sellCompact

        return Promise.resolve(accumulator)
      },
      Promise.resolve({
        tokens: [],
        baseBuys: [],
        baseSells: [],
        compactBuys: {},
        compactSells: {}
      })
    )

    let compactInputs = buildCompactBulk(
      data.compactBuys,
      data.compactSells,
      indices
    )
    compactInputs.buyResults = compactInputs.buyResults.map(val =>
      Web3.utils.padLeft(Web3.utils.bytesToHex(val), 14)
    )
    compactInputs.sellResults = compactInputs.sellResults.map(val =>
      Web3.utils.padLeft(Web3.utils.bytesToHex(val), 14)
    )

    let tx
    if (data.tokens.length === 0) {
      tx = this.contract.methods.setCompactData(
        compactInputs.buyResults,
        compactInputs.sellResults,
        currentBlockNumber,
        compactInputs.indexResults
      )
    } else {
      tx = this.contract.methods.setBaseRate(
        data.tokens,
        data.baseBuys,
        data.baseSells,
        compactInputs.buyResults,
        compactInputs.sellResults,
        currentBlockNumber,
        compactInputs.indexResults
      )
    }

    const gas = await tx.estimateGas({ from: operatorAddress })
    return tx.send({
      from: operatorAddress,
      gas,
      gasPrice: gasPrice
    })
  }
}