polkadot-js/apps

View on GitHub
packages/page-referenda/src/util.ts

Summary

Maintainability
C
1 day
Test Coverage
// Copyright 2017-2024 @polkadot/app-referenda authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { ApiPromise } from '@polkadot/api';
import type { PalletConvictionVotingTally, PalletRankedCollectiveTally, PalletReferendaCurve, PalletReferendaReferendumInfoConvictionVotingTally, PalletReferendaReferendumInfoRankedCollectiveTally, PalletReferendaTrackInfo } from '@polkadot/types/lookup';
import type { CurveGraph, TrackDescription, TrackInfoExt } from './types.js';

import { getGovernanceTracks } from '@polkadot/apps-config';
import { BN, BN_BILLION, BN_ONE, BN_ZERO, bnMax, bnMin, formatNumber, objectSpread, stringPascalCase } from '@polkadot/util';

const CURVE_LENGTH = 500;

export function getTrackName (trackId: BN, { name }: PalletReferendaTrackInfo): string {
  return `${
    formatNumber(trackId)
  } / ${
    name
      .replace(/_/g, ' ')
      .split(' ')
      .map(stringPascalCase)
      .join(' ')
  }`;
}

export function getTrackInfo (api: ApiPromise, specName: string, palletReferenda: string, tracks: TrackDescription[], trackId?: number): TrackInfoExt | undefined {
  let info: TrackInfoExt | undefined;

  if (tracks && trackId !== undefined && trackId !== -1) {
    const originMap = getGovernanceTracks(api, specName, palletReferenda);
    const track = tracks.find(({ id }) => id.eqn(trackId));

    if (track && originMap) {
      const trackName = track.info.name.toString();
      const base = originMap.find(({ id, name }) =>
        id === trackId &&
        name === trackName
      );

      if (base) {
        info = objectSpread<TrackInfoExt>({
          track,
          trackName: getTrackName(track.id, track.info)
        }, base);
      }
    }
  }

  return info;
}

export function isConvictionTally (tally: PalletRankedCollectiveTally | PalletConvictionVotingTally): tally is PalletConvictionVotingTally {
  return !!(tally as PalletConvictionVotingTally).support && !(tally as PalletRankedCollectiveTally).bareAyes;
}

export function isConvictionVote (info: PalletReferendaReferendumInfoConvictionVotingTally | PalletReferendaReferendumInfoRankedCollectiveTally): info is PalletReferendaReferendumInfoConvictionVotingTally {
  return info.isOngoing && isConvictionTally(info.asOngoing.tally);
}

export function curveThreshold (curve: PalletReferendaCurve, input: BN, div: BN): BN {
  // if divisor is zero, we return the max
  if (div.isZero()) {
    return BN_BILLION;
  }

  const x = input.mul(BN_BILLION).div(div);

  if (curve.isLinearDecreasing) {
    const { ceil, floor, length } = curve.asLinearDecreasing;

    // *ceil - (x.min(*length).saturating_div(*length, Down) * (*ceil - *floor))
    // NOTE: We first multiply, then divide (since we work with fractions)
    return ceil.sub(
      bnMin(x, length)
        .mul(ceil.sub(floor))
        .div(length)
    );
  } else if (curve.isSteppedDecreasing) {
    const { begin, end, period, step } = curve.asSteppedDecreasing;

    // (*begin - (step.int_mul(x.int_div(*period))).min(*begin)).max(*end)
    return bnMax(
      end,
      begin.sub(
        bnMin(
          begin,
          step
            .mul(x)
            .div(period)
        )
      )
    );
  } else if (curve.isReciprocal) {
    const { factor, xOffset, yOffset } = curve.asReciprocal;
    const div = x.add(xOffset);

    if (div.isZero()) {
      return BN_BILLION;
    }

    // factor
    //   .checked_rounding_div(FixedI64::from(x) + *x_offset, Low)
    //   .map(|yp| (yp + *y_offset).into_clamped_perthing())
    //   .unwrap_or_else(Perbill::one)
    return bnMin(
      BN_BILLION,
      factor
        .mul(BN_BILLION)
        .div(div)
        .add(yOffset)
    );
  }

  throw new Error(`Unknown curve found ${curve.type}`);
}

export function curveDelay (curve: PalletReferendaCurve, input: BN, div: BN): BN {
  try {
    // if divisor is zero, we return the max
    if (div.isZero()) {
      return BN_BILLION;
    }

    const y = input.mul(BN_BILLION).div(div);

    if (curve.isLinearDecreasing) {
      const { ceil, floor, length } = curve.asLinearDecreasing;

      // if y < *floor {
      //   Perbill::one()
      // } else if y > *ceil {
      //   Perbill::zero()
      // } else {
      //   (*ceil - y).saturating_div(*ceil - *floor, Up).saturating_mul(*length)
      // }
      return y.lt(floor)
        ? BN_BILLION
        : y.gt(ceil)
          ? BN_ZERO
          : bnMin(
            BN_BILLION,
            bnMax(
              BN_ZERO,
              ceil
                .sub(y)
                .mul(length)
                .div(ceil.sub(floor))
            )
          );
    } else if (curve.isSteppedDecreasing) {
      const { begin, end, period, step } = curve.asSteppedDecreasing;

      // if y < *end {
      //   Perbill::one()
      // } else {
      //   period.int_mul((*begin - y.min(*begin) + step.less_epsilon()).int_div(*step))
      // }
      return y.lt(end)
        ? BN_BILLION
        : bnMin(
          BN_BILLION,
          bnMax(
            BN_ZERO,
            period
              .mul(
                begin
                  .sub(bnMin(y, begin))
                  .add(
                    step.isZero()
                      ? step
                      : step.sub(BN_ONE)
                  )
              )
              .div(step)
          )
        );
    } else if (curve.asReciprocal) {
      const { factor, xOffset, yOffset } = curve.asReciprocal;
      const div = y.sub(yOffset);

      if (div.isZero()) {
        return BN_BILLION;
      }

      // let y = FixedI64::from(y);
      // let maybe_term = factor.checked_rounding_div(y - *y_offset, High);
      // maybe_term
      //   .and_then(|term| (term - *x_offset).try_into_perthing().ok())
      //   .unwrap_or_else(Perbill::one)
      return bnMin(
        BN_BILLION,
        bnMax(
          BN_ZERO,
          factor
            .mul(BN_BILLION)
            .div(div)
            .sub(xOffset)
        )
      );
    }
  } catch (error) {
    console.error(`Failed on curve ${curve.type}:`, curve.inner.toHuman());

    throw error;
  }

  throw new Error(`Unknown curve found ${curve.type}`);
}

export function calcDecidingEnd (totalEligible: BN, tally: PalletRankedCollectiveTally | PalletConvictionVotingTally, { decisionPeriod, minApproval, minSupport }: PalletReferendaTrackInfo, since: BN): BN | undefined {
  const support = isConvictionTally(tally)
    ? tally.support
    : tally.bareAyes;

  return since.add(
    decisionPeriod
      .mul(
        bnMax(
          curveDelay(minApproval, tally.ayes, tally.ayes.add(tally.nays)),
          curveDelay(minSupport, support, totalEligible)
        )
      )
      .div(BN_BILLION)
  );
}

export function calcCurves ({ decisionPeriod, minApproval, minSupport }: PalletReferendaTrackInfo): CurveGraph {
  const approval = new Array<BN>(CURVE_LENGTH);
  const support = new Array<BN>(CURVE_LENGTH);
  const x = new Array<BN>(CURVE_LENGTH);
  const step = decisionPeriod.divn(CURVE_LENGTH);
  const last = CURVE_LENGTH - 1;
  let current = new BN(0);

  for (let i = 0; i < last; i++) {
    approval[i] = curveThreshold(minApproval, current, decisionPeriod);
    support[i] = curveThreshold(minSupport, current, decisionPeriod);
    x[i] = current;

    current = current.add(step);
  }

  // since we may be lossy with the step, we explicitly calc the final point at 100%
  approval[last] = curveThreshold(minApproval, decisionPeriod, decisionPeriod);
  support[last] = curveThreshold(minSupport, decisionPeriod, decisionPeriod);
  x[last] = decisionPeriod;

  return { approval, support, x };
}