From 36be1d209a83eb2e45618a07902def242e4b7835 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Thu, 20 Apr 2023 14:11:17 +0200 Subject: [PATCH] lib: add base58 decoder --- .../kotlin/im/molly/monero/util/Base58.kt | 70 ++++++++ .../kotlin/im/molly/monero/util/Base58Test.kt | 168 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt create mode 100644 lib/android/src/test/kotlin/im/molly/monero/util/Base58Test.kt diff --git a/lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt b/lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt new file mode 100644 index 0000000..d72e5a1 --- /dev/null +++ b/lib/android/src/main/kotlin/im/molly/monero/util/Base58.kt @@ -0,0 +1,70 @@ +package im.molly.monero.util + +import java.nio.ByteBuffer +import java.nio.charset.Charset + +const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +object Decoder { + private val decodingTable = IntArray(128) { ALPHABET.indexOf(it.toChar()) } + + private val blockSizes = listOf(0, -1, 1, 2, -1, 3, 4, 5, -1, 6, 7, 8) + + fun decode(input: ByteArray): ByteBuffer { + val size = input.size + val needed = 8 * (size / 11) + findOutputBlockSize(size % 11) + val out = ByteBuffer.allocate(needed) + var pos = 0 + while (pos + 11 <= size) { + decodeBlock(input, pos, 11, out) + pos += 11 + } + val remain = size - pos + if (remain > 0) { + decodeBlock(input, pos, remain, out) + } + out.flip() + return out + } + + private fun decodeBlock(block: ByteArray, offset: Int, len: Int, out: ByteBuffer) { + val blockSize = findOutputBlockSize(len) + val newOutPos = out.position() + blockSize + + var num = 0uL + var base = 1uL + var zeroes = 0 + + for (i in (offset + len - 1) downTo offset) { + val c = block[i].toInt() + val digit = decodingTable.getOrElse(c) { -1 }.toULong() + require(digit >= 0uL) { "Invalid symbol" } + if (digit == 0uL) { + zeroes++ + } else { + while (zeroes > 0) { + base *= 58u + zeroes-- + } + val prod = digit * base + val lastNum = num + num += prod + require((prod / base == digit) && (num > lastNum)) { "Overflow" } + base *= 58u // Never overflows, 58^10 < 2^64 + } + } + for (j in 1..blockSize) { + out.put(newOutPos - j, num.toByte()) + num = num shr 8 + } + require(num == 0uL) { "Overflow" } + out.position(newOutPos) + } + + private fun findOutputBlockSize(blockSize: Int): Int = blockSizes[blockSize].also { + require(it >= 0) { "Invalid block size" } + } +} + +fun String.decodeBase58(): ByteArray = + Decoder.decode(this.toByteArray(Charset.defaultCharset())).array() diff --git a/lib/android/src/test/kotlin/im/molly/monero/util/Base58Test.kt b/lib/android/src/test/kotlin/im/molly/monero/util/Base58Test.kt new file mode 100644 index 0000000..c92b8f4 --- /dev/null +++ b/lib/android/src/test/kotlin/im/molly/monero/util/Base58Test.kt @@ -0,0 +1,168 @@ +package im.molly.monero.util + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert +import org.junit.Test + +class Base58Test { + + // Test cases from monero unit_tests/base58.cpp + + private val base58ToHex = mapOf( + // 2-bytes block + "11" to "00", + "1z" to "39", + "5Q" to "FF", + // 3-bytes block + "111" to "0000", + "11z" to "0039", + "15R" to "0100", + "LUv" to "FFFF", + // 5-bytes block + "11111" to "000000", + "1111z" to "000039", + "11LUw" to "010000", + "2UzHL" to "FFFFFF", + // 6-bytes block + "11111z" to "00000039", + "7YXq9G" to "FFFFFFFF", + // 7-bytes block + "111111z" to "0000000039", + "VtB5VXc" to "FFFFFFFFFF", + // 9-bytes block + "11111111z" to "000000000039", + "3CUsUpv9t" to "FFFFFFFFFFFF", + // 10-bytes block + "111111111z" to "00000000000039", + "Ahg1opVcGW" to "FFFFFFFFFFFFFF", + // 11-bytes block + "1111111111z" to "0000000000000039", + "jpXCZedGfVQ" to "FFFFFFFFFFFFFFFF", + "11111111111" to "0000000000000000", + "11111111112" to "0000000000000001", + "11111111119" to "0000000000000008", + "1111111111A" to "0000000000000009", + "11111111121" to "000000000000003A", + "1Ahg1opVcGW" to "00FFFFFFFFFFFFFF", + "22222222222" to "06156013762879F7", + "1z111111111" to "05E022BA374B2A00", + // Multiple blocks + "1111111111111" to "000000000000000000", + "11111111111111" to "00000000000000000000", + "1111111111111111" to "0000000000000000000000", + "11111111111111111" to "000000000000000000000000", + "111111111111111111" to "00000000000000000000000000", + "11111111111111111111" to "0000000000000000000000000000", + "111111111111111111111" to "000000000000000000000000000000", + "1111111111111111111111" to "00000000000000000000000000000000", + "jpXCZedGfVQ5Q" to "FFFFFFFFFFFFFFFFFF", + "jpXCZedGfVQLUv" to "FFFFFFFFFFFFFFFFFFFF", + "jpXCZedGfVQ2UzHL" to "FFFFFFFFFFFFFFFFFFFFFF", + "jpXCZedGfVQ7YXq9G" to "FFFFFFFFFFFFFFFFFFFFFFFF", + "22222222222VtB5VXc" to "06156013762879F7FFFFFFFFFF", + "jpXCZedGfVQVtB5VXc" to "FFFFFFFFFFFFFFFFFFFFFFFFFF", + "jpXCZedGfVQ3CUsUpv9t" to "FFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "jpXCZedGfVQAhg1opVcGW" to "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "jpXCZedGfVQjpXCZedGfVQ" to "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + ) + + private val overflows = listOf( + "5R", + "zz", + "LUw", + "zzz", + "2UzHM", + "zzzzz", + "7YXq9H", + "zzzzzz", + "VtB5VXd", + "zzzzzzz", + "3CUsUpv9u", + "zzzzzzzzz", + "Ahg1opVcGX", + "zzzzzzzzzz", + "jpXCZedGfVR", + "zzzzzzzzzzz", + "123456789AB5R", + "123456789ABzz", + "123456789ABLUw", + "123456789ABzzz", + "123456789AB2UzHM", + "123456789ABzzzzz", + "123456789AB7YXq9H", + "123456789ABzzzzzz", + "123456789ABVtB5VXd", + "123456789ABzzzzzzz", + "123456789AB3CUsUpv9u", + "123456789ABzzzzzzzzz", + "123456789ABAhg1opVcGX", + "123456789ABzzzzzzzzzz", + "123456789ABjpXCZedGfVR", + "123456789ABzzzzzzzzzzz", + "zzzzzzzzzzz11", + ) + + private val invalidSymbols = listOf( + "10", + "11I", + "11O11", + "11l111", + "11_11111111", + "1101111111111", + "11I11111111111111", + "11O1111111111111111111", + "1111111111110", + "111111111111l1111", + "111111111111_111111111", + ) + + private val invalidLengths = listOf( + "1", + "z", + "1111", + "zzzz", + "11111111", + "zzzzzzzz", + "123456789AB1", + "123456789ABz", + "123456789AB1111", + "123456789ABzzzz", + "123456789AB11111111", + "123456789ABzzzzzzzz", + ) + + @Test + fun `decode valid base58 strings`() { + base58ToHex.forEach { (input, expected) -> + assertThat(input.decodeBase58()).isEqualTo(expected.parseHex()) + } + } + + @Test + fun `empty string decodes to zero length`() { + assertThat("".decodeBase58()).hasLength(0) + } + + @Test + fun `error on overflows`() { + overflows.forEach { + val thrown = + Assert.assertThrows(IllegalArgumentException::class.java) { it.decodeBase58() } + assertThat(thrown.message).ignoringCase().contains("overflow") + } + } + + @Test + fun `error decoding invalid lengths`() { + invalidLengths.forEach { + Assert.assertThrows(IllegalArgumentException::class.java) { it.decodeBase58() } + } + } + + @Test + fun `error decoding invalid symbols`() { + invalidSymbols.forEach { + Assert.assertThrows(IllegalArgumentException::class.java) { it.decodeBase58() } + } + } +}