The problem came when I wanted to pass down BigDecimal’s unscaledBytes from kotlin/java to javascript through gRPC-Web.

Java’s implementation of the unscaledBytes returns signed big-endian bytes

Javascript has BigInt support, but lacks of toBytes(), stuff around the internet all seems to handle positive numbers just fine, but fails in negative number implementation

I borrowed from a Go implementation and adapted to javascript

Javascript implementation (using Typescript) Link to heading

const TWO_POWER_31 = 2147483648

const big0 = BigInt(0)
const big1 = BigInt(1)
const big8 = BigInt(8)

export class BigDecimal {
  private big?: bigint
  private num?: number
  private isNumber: boolean
  scale: number

  constructor(int: bigint | number, scale?: number) {
    this.isNumber = int < TWO_POWER_31 && int >= -TWO_POWER_31
    if (!this.isNumber) {
      this.big = BigInt(int)
    } else {
      const n = Number(int)
      if (Math.floor(n) !== n) {
        throw new Error('big decimal only accepts integer and scale')
      }
      this.num = n
    }
    this.scale = scale || 0
  }

  bigToUint8Array() {
    let big: bigint = this.big!!
    if (big < big0) {
      const bits: bigint = (BigInt(big.toString(2).length) / big8 + big1) * big8
      const prefix1: bigint = big1 << bits
      big += prefix1
    }
    let hex = big.toString(16)
    if (hex.length % 2) {
      hex = '0' + hex
    } else if (hex[0] === '8') {
      // maximum positive need to prepend 0 otherwise resuts in negative number
      hex = '00' + hex
    }
    const len = hex.length / 2
    const u8 = new Uint8Array(len)
    var i = 0
    var j = 0
    while (i < len) {
      u8[i] = parseInt(hex.slice(j, j + 2), 16)
      i += 1
      j += 2
    }
    return u8
  }

  // positive save integer to array
  numToUint8Array(): Uint8Array {
    let n = this.num!!
    const arr: number[] = []
    while (true) {
      if (!n) {
        break
      }
      arr.unshift(n & 0xff)
      n >>>= 8
    }
    if (arr.length === 0) {
      return new Uint8Array([0])
    }
    if (arr[0] & 0x80) {
      arr.unshift(0)
    }
    // TODO: we could eliminate leading 1's e.g. 0xff80 == 0x80
    return new Uint8Array(arr)
  }

  unscaledBytes(): Uint8Array {
    if (this.isNumber) {
      if (this.num!! < 0) {
        this.big = BigInt(this.num)
      } else {
        return this.numToUint8Array()
      }
    }
    return this.bigToUint8Array()
  }

  getScale(): number {
    return this.scale
  }

  toString() {
    // TODO: should try to improve this
    // one idea is to put negative to the class. if we do this, we should
    // improve the bigInt parsing as well as parsing positive is easier than
    // parsing 2s compliment integer
    let s: string
    if (this.isNumber) {
      s = this.num!!.toString()
    } else {
      s = this.big!!.toString()
    }
    if (!this.scale) {
      return s
    }
    const negative = s.charAt(0) === '-'
    if (negative) {
      s = s.slice(1)
    }
    if (this.scale > s.length) {
      s = s.padStart(this.scale, '0')
    }
    let dot = s.length - this.scale
    if (dot === 0) {
      s = '0'.concat(s)
      dot = 1
    }
    s = s.slice(0, dot).concat('.').concat(s.slice(dot))
    if (negative) {
      return '-'.concat(s)
    }
    return s
  }

  toJSON() {
    return this.toString()
  }

  equals(other: unknown) {
    const n = this.isNumber ? this.num : this.big
    if (other instanceof BigDecimal) {
      const m = other.isNumber ? other.num : other.big
      // eslint-disable-next-line eqeqeq
      return m == n
    }
    if (typeof other === 'number') {
      // eslint-disable-next-line eqeqeq
      return n == other
    }
    if (typeof other === 'string') {
      return this.toString() === other
    }
    return false
  }

  static fromBytes(a: Uint8Array, scale?: number): BigDecimal {
    if (!a.length) {
      return new BigDecimal(0)
    }
    const hex = Buffer.from(a).toString('hex')
    let big = BigInt('0x' + hex)
    if (a[0] & 0x80) {
      const negative = BigInt('0x1' + '0'.repeat(hex.length))
      big -= negative
    }
    return new BigDecimal(big, scale)
  }

  static fromString(s: string): BigDecimal {
    const dot = s.indexOf('.')
    const minus = s.indexOf('-')
    if (dot === -1) {
      return new BigDecimal(BigInt(s), 0)
    }
    if (dot === s.length - 1) {
      s = s.slice(0, s.length - 1)
    }
    // .-1234
    if (dot === 0 && minus === 1) {
      throw new Error('invalid big decimal number'.concat(s))
    }
    s = s.slice(0, dot).concat(s.slice(dot + 1))
    // -.1234 = -0.1234
    if (dot === 1 && minus === 0) {
      // TODO
    }
    return new BigDecimal(BigInt(s), s.length - dot)
  }
}

Go implementation Link to heading

var one = big.NewInt(1)

// SetSignedBytes sets the value of n to the big-endian two's complement
// value stored in the given data. If data[0]&80 != 0, the number
// is negative. If data is empty, the result will be 0.
func SetSignedBytes(n *big.Int, data []byte) {
	n.SetBytes(data)
	if len(data) > 0 && data[0]&0x80 > 0 {
		n.Sub(n, new(big.Int).Lsh(one, uint(len(data))*8))
	}
}

// SignedBytes returns the big-endian two's complement
// form of n.
func SignedBytes(n *big.Int) []byte {
	switch n.Sign() {
	case 0:
		return []byte{0}
	case 1:
		b := n.Bytes()
		if b[0]&0x80 > 0 {
			b = append([]byte{0}, b...)
		}
		return b
	case -1:
		length := uint(n.BitLen()/8+1) * 8
		b := new(big.Int).Add(n, new(big.Int).Lsh(one, length)).Bytes()
		// When the most significant bit is on a byte
		// boundary, we can get some extra significant
		// bits, so strip them off when that happens.
		if len(b) >= 2 && b[0] == 0xff && b[1]&0x80 != 0 {
			b = b[1:]
		}
		return b
	}
	panic("unreachable")
}