From 8cff34abb42fd340acb35e0f3172955b5d4412f3 Mon Sep 17 00:00:00 2001 From: Craig Everett Date: Wed, 12 Aug 2026 17:24:13 +0900 Subject: [PATCH] WIP --- gajumaru-core/src/Testinator.java | 149 ++++++-- .../qpq/gajumaru/core/encoding/Base58.java | 72 ++-- .../swiss/qpq/gajumaru/core/encoding/RLP.java | 215 +++++++----- .../gajumaru/core/formatting/GajuFormat.java | 325 +++++++++++------- gajumaru-core/test/src/gmt.erl | 292 +++++++++++++--- 5 files changed, 746 insertions(+), 307 deletions(-) diff --git a/gajumaru-core/src/Testinator.java b/gajumaru-core/src/Testinator.java index bacf7a5..e01796a 100644 --- a/gajumaru-core/src/Testinator.java +++ b/gajumaru-core/src/Testinator.java @@ -2,8 +2,13 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; +import java.util.ArrayList; +import java.util.List; +import java.math.BigInteger; import swiss.qpq.gajumaru.core.encoding.Base58; +import swiss.qpq.gajumaru.core.encoding.RLP; +import swiss.qpq.gajumaru.core.formatting.GajuFormat; public class Testinator { public static void main(String[] args) { @@ -20,11 +25,23 @@ public class Testinator { case "base58" -> { System.out.print(base58(args[1])); } + case "base58_check" -> { + System.out.print(base58_check(args[1])); + } case "rlp" -> { System.out.print(rlp(args[1])); } + case "rlp_stream" -> { + System.out.print(rlp_stream(args[1])); + } + case "rlp_fail" -> { + System.out.print(rlp_fail(args[1])); + } + case "gaju_format" -> { + System.out.print(gaju_format(args[1])); + } } - } catch (IOException e) { + } catch (Exception e) { System.err.println("Error: " + e.getMessage()); System.exit(1); } @@ -32,38 +49,124 @@ public class Testinator { private static String base64(String workingPath) throws IOException { Path testPath = Path.of(workingPath, "base64.test"); - Path encPath = Path.of(workingPath, "base64.java.txt"); - Path decPath = Path.of(workingPath, "base64.java.back"); - byte[] rawBytes = Files.readAllBytes(testPath); - byte[] encBytes = Base64.getEncoder().encode(rawBytes); - Files.write(encPath, encBytes); - byte[] readEncBytes = Files.readAllBytes(encPath); - byte[] decBytes = Base64.getDecoder().decode(readEncBytes); - Files.write(decPath, decBytes); + Path encPath = Path.of(workingPath, "base64.java.txt"); + Path decPath = Path.of(workingPath, "base64.java.back"); + byte[] rawB = Files.readAllBytes(testPath); + byte[] encB = Base64.getEncoder().encode(rawB); + Files.write(encPath, encB); + byte[] readE = Files.readAllBytes(encPath); + byte[] decB = Base64.getDecoder().decode(readE); + Files.write(decPath, decB); return encPath.toString() + " " + decPath.toString(); } private static String base58(String workingPath) throws IOException { Path testPath = Path.of(workingPath, "base58.test"); - Path encPath = Path.of(workingPath, "base58.java.txt"); - Path decPath = Path.of(workingPath, "base58.java.back"); - byte[] rawBytes = Files.readAllBytes(testPath); - String encString = Base58.encode(rawBytes); - Files.write(encPath, encString.getBytes()); - byte[] readEncBytes = Files.readAllBytes(encPath); - String readEncString = new String(readEncBytes); - byte[] decBytes = Base58.decode(readEncString); - Files.write(decPath, decBytes); + Path encPath = Path.of(workingPath, "base58.java.txt"); + Path decPath = Path.of(workingPath, "base58.java.back"); + byte[] rawB = Files.readAllBytes(testPath); + String encS = Base58.encode(rawB); + Files.write(encPath, encS.getBytes()); + byte[] readE = Files.readAllBytes(encPath); + String readS = new String(readE); + byte[] decB = Base58.decode(readS); + Files.write(decPath, decB); + return encPath.toString() + " " + decPath.toString(); + } + + private static String base58_check(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "base58_check.test"); + Path encPath = Path.of(workingPath, "base58_check.java.txt"); + Path decPath = Path.of(workingPath, "base58_check.java.back"); + byte[] rawB = Files.readAllBytes(testPath); + String encS = Base58.checkEncode(rawB); + Files.write(encPath, encS.getBytes()); + byte[] readE = Files.readAllBytes(encPath); + String readS = new String(readE); + byte[] decB = Base58.checkDecode(readS); + Files.write(decPath, decB); return encPath.toString() + " " + decPath.toString(); } private static String rlp(String workingPath) throws IOException { Path testPath = Path.of(workingPath, "rlp.test"); - Path encPath = Path.of(workingPath, "rlp.java.back"); - byte[] encBytes = Files.readAllBytes(testPath); - RLP_Data decRLP = RLP.decode(encBytes), + Path resPath = Path.of(workingPath, "rlp.java.back"); + byte[] encB = Files.readAllBytes(testPath); - Files.write(decPath, decBytes); - return encPath.toString() + " " + decPath.toString(); + RLP.RLP_Data decRLP = RLP.decode(encB); + + RLP.RLP_List root = decRLP.asList(); + RLP.RLP_List sub = root.getItems().get(1).asList(); + byte[] anchor = sub.getItems().get(1).asItem().getBytes(); + + byte[] reEnc = RLP.encode(decRLP); + Files.write(resPath, reEnc); + + return new String(anchor) + "|" + resPath.toString(); + } + + private static String rlp_stream(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "rlp_stream.test"); + byte[] buffer = Files.readAllBytes(testPath); + + List hashes = new ArrayList<>(); + int offset = 0; + while (offset < buffer.length) { + RLP.DecodeResult res = RLP.decodeFrom(buffer, offset); + hashes.add(Integer.toString(res.data().hashCode())); // Just to verify we got objects + offset += res.consumed(); + } + return Integer.toString(hashes.size()); + } + + private static String rlp_fail(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "rlp_fail.test"); + byte[] buffer = Files.readAllBytes(testPath); + try { + RLP.decode(buffer); + return "SUCCESS"; + } catch (RLP.RLPException e) { + return "RLP_EXCEPTION: " + e.getMessage(); + } catch (Exception e) { + return "OTHER_EXCEPTION: " + e.getClass().getSimpleName(); + } + } + + private static String gaju_format(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "gaju_format.test"); + Path resPath = Path.of(workingPath, "gaju_format.java.txt"); + List lines = Files.readAllLines(testPath); + List results = new ArrayList<>(); + + for (String line : lines) { + if (line.trim().isEmpty()) continue; + String[] p = line.split("\\|"); + String op = p[0]; + switch (op) { + case "amount" -> { + GajuFormat.Type style = GajuFormat.Type.valueOf(p[1]); + GajuFormat.Unit unit = GajuFormat.Unit.valueOf(p[2]); + char sep = p[3].charAt(0); + int span = Integer.parseInt(p[4]); + BigInteger val = new BigInteger(p[5]); + results.add(GajuFormat.amount(new GajuFormat.FormatSpec(style, unit, sep, span), val)); + } + case "approx" -> { + GajuFormat.Type style = GajuFormat.Type.valueOf(p[1]); + GajuFormat.Unit unit = GajuFormat.Unit.valueOf(p[2]); + char sep = p[3].charAt(0); + int span = Integer.parseInt(p[4]); + BigInteger val = new BigInteger(p[5]); + int prec = Integer.parseInt(p[6]); + results.add(GajuFormat.approxAmount(new GajuFormat.FormatSpec(style, unit, sep, span), val, prec)); + } + case "read" -> { + byte[] b = GajuFormat.read(p[1]); + results.add(new BigInteger(b).toString()); + } + } + } + Files.write(resPath, results); + return resPath.toString(); } } diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java index c9a9a01..cacd556 100644 --- a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java @@ -20,9 +20,12 @@ package swiss.qpq.gajumaru.core.encoding; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Arrays; -// Stateless Base58 implementation. +// Stateless Base58 and Base58Check implementation. + public final class Base58 { private static final char[] ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); @@ -52,25 +55,20 @@ public final class Base58 { char[] encoded = new char[temp.length * 2]; int outputStart = encoded.length; - // Treat 'temp' as one giant number. Process until the entire array is reduced to zeros. - // i indicates the position of the most significant leading zero. int i = zeros; while (i < temp.length) { int remainder = 0; - // Perform long division from left to right across the active bytes for (int j = i; j < temp.length; j++) { int currentByte = temp[j] & 0xFF; // Safe unsigned byte conversion int totalValue = (remainder * 256) + currentByte; - temp[j] = (byte) (totalValue / 58); // Mutate array with the quotient - remainder = totalValue % 58; // Carry the remainder to the next byte + temp[j] = (byte) (totalValue / 58); + remainder = totalValue % 58; } - // The final remainder of this pass is our next Base58 digit character encoded[--outputStart] = ALPHABET[remainder]; - // Advance the pointer forward if the current head byte has been ground down to 0 if (temp[i] == 0) { i++; } @@ -94,19 +92,16 @@ public final class Base58 { return new byte[0]; } - // Convert the string into numeric index offsets byte[] input58 = new byte[input.length()]; for (int i = 0; i < input.length(); i++) { char c = input.charAt(i); int digit = (c < 128) ? INDEXES[c] : -1; if (digit < 0) { - // I picked the wrong week to stop drinking... - throw new IllegalArgumentException("Illegal Base58 character encountered: " + c); + throw new IllegalArgumentException(String.format("Illegal Base58 character '%c' at index %d", c, i)); } input58[i] = (byte) digit; } - // Count leading zeros to reconstruct leading 0 bytes int zeros = 0; while (zeros < input58.length && input58[zeros] == 0) { zeros++; @@ -120,33 +115,70 @@ public final class Base58 { while (i < input58.length) { int remainder = 0; - // Long division from left to right across the remaining numeric character indices for (int j = i; j < input58.length; j++) { int currentBase58Digit = input58[j] & 0xFF; int totalValue = (remainder * 58) + currentBase58Digit; // Shift base by 58 - input58[j] = (byte) (totalValue / 256); // Mutate array with the base-256 quotient - remainder = totalValue % 256; // Carry the byte remainder forward + input58[j] = (byte) (totalValue / 256); + remainder = totalValue % 256; } - // The remainder of this pass is the next raw base-256 byte payload decoded[--outputStart] = (byte) remainder; - // Advance past this leading index if its value has been exhausted or is 0 while (i < input58.length && input58[i] == 0) { i++; } } - - // Strip extra leading zero allocations from the worst-case boundary buffer while (outputStart < decoded.length && decoded[outputStart] == 0) { outputStart++; } - // Re-inject the necessary leading padding zeros byte[] result = new byte[decoded.length - outputStart + zeros]; System.arraycopy(decoded, outputStart, result, zeros, decoded.length - outputStart); return result; } + + + // Base58Check + + //Encodes bytes with a 4-byte double-SHA256 checksum. + public static String checkEncode(byte[] input) { + byte[] checksum = doubleSha256(input); + byte[] combined = new byte[input.length + 4]; + System.arraycopy(input, 0, combined, 0, input.length); + System.arraycopy(checksum, 0, combined, input.length, 4); + return encode(combined); + } + + // Decodes a Base58Check string and validates the checksum. + public static byte[] checkDecode(String input) throws IllegalArgumentException { + byte[] decoded = decode(input); + if (decoded.length < 4) { + throw new IllegalArgumentException("Base58Check input too short"); + } + + byte[] data = Arrays.copyOfRange(decoded, 0, decoded.length - 4); + byte[] actual = Arrays.copyOfRange(decoded, decoded.length - 4, decoded.length); + byte[] expected = doubleSha256(data); + + for (int i = 0; i < 4; i++) { + if (actual[i] != expected[i]) { + throw new IllegalArgumentException("Base58Check checksum mismatch"); + } + } + return data; + } + + + // Internal Utilities + + private static byte[] doubleSha256(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(digest.digest(data)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } } diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/RLP.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/RLP.java index 5488dc1..b195347 100644 --- a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/RLP.java +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/encoding/RLP.java @@ -30,22 +30,55 @@ public final class RLP { // RLP only has two types: byte arrays and lists of lists of byte arrays // TODO: Comment this better with references. - public abstract static class RLP_Data {} + public static class RLPException extends RuntimeException { + public RLPException(String message) { + super(message); + } + } + + + // Data Models - lists and items + + public abstract static class RLP_Data { + public boolean isItem() { return this instanceof RLP_Item; } + public boolean isList() { return this instanceof RLP_List; } + + public RLP_Item asItem() { + if (isItem()) return (RLP_Item) this; + throw new RLPException("Expected RLP Item, found List"); + } + + public RLP_List asList() { + if (isList()) return (RLP_List) this; + throw new RLPException("Expected RLP List, found Item"); + } + } public static final class RLP_Item extends RLP_Data { public final byte[] bytes; + public RLP_Item(byte[] bytes) { - this.bytes = bytes != null ? bytes : new byte[0]; + this.bytes = (bytes != null) ? bytes : new byte[0]; } + + public byte[] getBytes() { return bytes; } } public static final class RLP_List extends RLP_Data { public final List items; + public RLP_List(List items) { - this.items = items != null ? items : new ArrayList<>(); + this.items = (items != null) ? items : new ArrayList<>(); } + + public List getItems() { return items; } } + public record DecodeResult(RLP_Data data, int consumed) {} + + + // API + private RLP() {} public static byte[] encode(RLP_Data data) { @@ -54,7 +87,67 @@ public final class RLP { } else if (data instanceof RLP_List list) { return encodeList(list.items); } - throw new IllegalArgumentException("Unsupported RLP type"); + throw new RLPException("Unsupported RLP data type"); + } + + public static RLP_Data decode(byte[] buffer) { + if (buffer == null || buffer.length == 0) { + return new RLP_Item(new byte[0]); + } + DecodeResult result = decodeFrom(buffer, 0); + if (result.consumed != buffer.length) { + throw new RLPException("Buffer contains " + (buffer.length - result.consumed) + " trailing bytes"); + } + return result.data; + } + + // Decodes a single RLP object from a buffer starting at the given offset. + // Useful for stream decoding. + public static DecodeResult decodeFrom(byte[] buffer, int offset) { + if (buffer == null || offset >= buffer.length) { + throw new RLPException("Buffer underflow at offset " + offset); + } + + int prefix = buffer[offset] & 0xFF; + + // 1. Single byte [0x00, 0x7F] (itself) + if (prefix <= 0x7F) { + return new DecodeResult(new RLP_Item(new byte[] { (byte) prefix }), 1); + } + + // 2. Short string [0x80, 0xB7] (length 0-55) + if (prefix <= 0xB7) { + int payloadLen = prefix - 0x80; + checkBounds(buffer, offset + 1, payloadLen); + byte[] payload = Arrays.copyOfRange(buffer, offset + 1, offset + 1 + payloadLen); + return new DecodeResult(new RLP_Item(payload), 1 + payloadLen); + } + + // 3. Long string [0xB8, 0xBF] (length > 55) + if (prefix <= 0xBF) { + int lenLen = prefix - 0xB7; + checkBounds(buffer, offset + 1, lenLen); + int payloadLen = bigEndianToInt(buffer, offset + 1, offset + 1 + lenLen); + checkBounds(buffer, offset + 1 + lenLen, payloadLen); + byte[] payload = Arrays.copyOfRange(buffer, offset + 1 + lenLen, offset + 1 + lenLen + payloadLen); + return new DecodeResult(new RLP_Item(payload), 1 + lenLen + payloadLen); + } + + // 4. Short list [0xC0, 0xF7] (total length 0-55) + if (prefix <= 0xF7) { + int payloadLen = prefix - 0xC0; + checkBounds(buffer, offset + 1, payloadLen); + RLP_List list = parseListSequence(buffer, offset + 1, offset + 1 + payloadLen); + return new DecodeResult(list, 1 + payloadLen); + } + + // 5. Long list [0xF8, 0xFF] (total length > 55) + int lenLen = prefix - 0xF7; + checkBounds(buffer, offset + 1, lenLen); + int payloadLen = bigEndianToInt(buffer, offset + 1, offset + 1 + lenLen); + checkBounds(buffer, offset + 1 + lenLen, payloadLen); + RLP_List list = parseListSequence(buffer, offset + 1 + lenLen, offset + 1 + lenLen + payloadLen); + return new DecodeResult(list, 1 + lenLen + payloadLen); } private static byte[] encodeItem(byte[] bytes) { @@ -69,25 +162,21 @@ public final class RLP { return new byte[] { (byte) 0xC0 }; } - // Pass 1: Encode all sub-elements and compute exact combined byte length byte[][] encodedChildren = new byte[items.size()][]; - int totalPayloadLength = 0; + int totalPayloadLen = 0; for (int i = 0; i < items.size(); i++) { encodedChildren[i] = encode(items.get(i)); - totalPayloadLength += encodedChildren[i].length; + totalPayloadLen += encodedChildren[i].length; } - // Pass 2: Generate the structural frame list marker - byte[] prefix = prefixLength(totalPayloadLength, 0xC0, 0xF7); - - // Pass 3: Flatten all segments directly into a single linear allocation - byte[] result = new byte[prefix.length + totalPayloadLength]; + byte[] prefix = prefixLength(totalPayloadLen, 0xC0, 0xF7); + byte[] result = new byte[prefix.length + totalPayloadLen]; System.arraycopy(prefix, 0, result, 0, prefix.length); - int writePtr = prefix.length; + int ptr = prefix.length; for (byte[] child : encodedChildren) { - System.arraycopy(child, 0, result, writePtr, child.length); - writePtr += child.length; + System.arraycopy(child, 0, result, ptr, child.length); + ptr += child.length; } return result; } @@ -100,92 +189,52 @@ public final class RLP { return result; } - private static byte[] prefixLength(int length, int shortOffset, int longOffset) { - if (length <= 55) { - return new byte[] { (byte) (shortOffset + length) }; + private static byte[] prefixLength(int len, int shortOffset, int longOffset) { + if (len <= 55) { + return new byte[] { (byte) (shortOffset + len) }; } - byte[] lengthBytes = intToBigEndian(length); - byte[] prefix = new byte[1 + lengthBytes.length]; - prefix[0] = (byte) (longOffset + lengthBytes.length); - System.arraycopy(lengthBytes, 0, prefix, 1, lengthBytes.length); - return prefix; + byte[] lenB = intToBigEndian(len); + byte[] p = new byte[1 + lenB.length]; + p[0] = (byte) (longOffset + lenB.length); + System.arraycopy(lenB, 0, p, 1, lenB.length); + return p; } private static byte[] intToBigEndian(int val) { if (val == 0) return new byte[0]; int size = (Integer.numberOfLeadingZeros(val) == 32) ? 1 : (32 - Integer.numberOfLeadingZeros(val) + 7) / 8; - byte[] sigBytes = new byte[size]; + byte[] b = new byte[size]; for (int i = size - 1; i >= 0; i--) { - sigBytes[i] = (byte) (val & 0xFF); + b[i] = (byte) (val & 0xFF); val >>>= 8; } - return sigBytes; + return b; } - - public static RLP_Data decode(byte[] bytes) { - if (bytes == null || bytes.length == 0) { - return new RLP_Item(new byte[0]); - } - return decodeRange(bytes, 0, bytes.length); - } - - private static RLP_Data decodeRange(byte[] bytes, int start, int end) { - int prefix = bytes[start] & 0xFF; - - if (prefix <= 0x7F) { - return new RLP_Item(new byte[] { (byte) prefix }); - } - if (prefix <= 0xB7) { - return new RLP_Item(Arrays.copyOfRange(bytes, start + 1, start + 1 + (prefix - 0x80))); - } - if (prefix <= 0xBF) { - int lenLen = prefix - 0xB7; - int len = bigEndianToInt(bytes, start + 1, start + 1 + lenLen); - return new RLP_Item(Arrays.copyOfRange(bytes, start + 1 + lenLen, start + 1 + lenLen + len)); - } - if (prefix <= 0xF7) { - int listLen = prefix - 0xC0; - return parseListSequence(bytes, start + 1, start + 1 + listLen); - } - - int lenLen = prefix - 0xF7; - int listLen = bigEndianToInt(bytes, start + 1, start + 1 + lenLen); - return parseListSequence(bytes, start + 1 + lenLen, start + 1 + lenLen + listLen); - } - - private static RLP_List parseListSequence(byte[] bytes, int cursor, int limit) { + private static RLP_List parseListSequence(byte[] b, int cursor, int limit) { List elements = new ArrayList<>(); while (cursor < limit) { - int itemStart = cursor; - int prefix = bytes[cursor] & 0xFF; - int elementTotalSize; - - if (prefix <= 0x7F) { - elementTotalSize = 1; - } else if (prefix <= 0xB7) { - elementTotalSize = 1 + (prefix - 0x80); - } else if (prefix <= 0xBF) { - int lenLen = prefix - 0xB7; - elementTotalSize = 1 + lenLen + bigEndianToInt(bytes, itemStart + 1, itemStart + 1 + lenLen); - } else if (prefix <= 0xF7) { - elementTotalSize = 1 + (prefix - 0xC0); - } else { - int lenLen = prefix - 0xF7; - elementTotalSize = 1 + lenLen + bigEndianToInt(bytes, itemStart + 1, itemStart + 1 + lenLen); - } - - elements.add(decodeRange(bytes, itemStart, itemStart + elementTotalSize)); - cursor += elementTotalSize; + DecodeResult res = decodeFrom(b, cursor); + elements.add(res.data); + cursor += res.consumed; + } + if (cursor != limit) { + throw new RLPException("List payload overflow or invalid child encoding"); } return new RLP_List(elements); } - private static int bigEndianToInt(byte[] bytes, int start, int end) { - int result = 0; + private static int bigEndianToInt(byte[] b, int start, int end) { + int res = 0; for (int i = start; i < end; i++) { - result = (result << 8) | (bytes[i] & 0xFF); + res = (res << 8) | (b[i] & 0xFF); + } + return res; + } + + private static void checkBounds(byte[] buffer, int start, int length) { + if (length < 0 || (start + length) > buffer.length) { + throw new RLPException("Incomplete RLP data: requested " + length + " bytes from offset " + start); } - return result; } } diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java index 2917fe7..cd3070c 100644 --- a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java @@ -1,9 +1,15 @@ package swiss.qpq.gajumaru.core.formatting; +import java.math.BigInteger; import java.util.Arrays; import java.util.HashMap; import java.util.Map; + +// GajuFormat provides formatting and parsing for Gaju and Puck units, +// maintaining 1-for-1 parity with the Erlang implementation (hz_format.erl). +// One Gaju = 10^18 Pucks. + public final class GajuFormat { public enum Type { US, JP, METRIC, LEGACY } public enum Unit { GAJU, PUCK } @@ -14,76 +20,116 @@ public final class GajuFormat { private static final String GAJU_MARK = "木"; private static final String PUCK_MARK = "本"; private static final int FRACTION_LEN = 18; + private static final BigInteger ONE_GAJU = BigInteger.TEN.pow(FRACTION_LEN); private static final String[] JP_RANKS = {"", "万", "億", "兆", "京", "垓", "秭", "穣", "溝", "澗", "正", "載", "極"}; - private static final String[] METRIC_RANKS = {"", "k", "m", "G", "T", "P", "E", "Z", "Y", "r", "Q"}; - private static final String[] LEGACY_RANKS = {"", "k", "m", "b", "T", "q", "E", "Z", "Y", "r", "Q"}; + private static final String[] METRIC_RANKS = {"", "k", "m", "g", "t", "p", "e", "z", "y", "r", "Q"}; + private static final String[] LEGACY_RANKS = {"", "k", "m", "b", "t", "q", "e", "z", "y", "r", "Q"}; public static String amount(FormatSpec spec, byte[] puckBytes) { - String base10 = GmMonetaryFormatter.bytesToBase10PuckString(puckBytes); - boolean isNegative = puckBytes.length > 0 && (puckBytes[0] & 0x80) != 0; // Handle sign tracking if applicable + return amount(spec, new BigInteger(puckBytes)); + } + + public static String amount(FormatSpec spec, BigInteger pucks) { + boolean isNegative = pucks.signum() < 0; + BigInteger absPucks = pucks.abs(); return switch (spec.type()) { - case US -> formatWestern(spec, base10); - case JP -> formatMyriad(spec, base10, JP_RANKS, "-"); - case METRIC -> formatBestern(spec, base10, METRIC_RANKS); - case LEGACY -> formatBestern(spec, base10, LEGACY_RANKS); + case US -> formatWestern(spec, absPucks, isNegative); + case JP -> formatMyriad(spec, absPucks, JP_RANKS, isNegative); + case METRIC -> formatBestern(spec, absPucks, METRIC_RANKS, isNegative, "G", "P"); + case LEGACY -> formatBestern(spec, absPucks, LEGACY_RANKS, isNegative, "G", "P"); }; } - private static String formatWestern(FormatSpec spec, String base10) { + public static String approxAmount(FormatSpec spec, BigInteger pucks, int precision) { + boolean isNegative = pucks.signum() < 0; + BigInteger absPucks = pucks.abs(); + if (spec.unit() == Unit.PUCK) { - return PUCK_MARK + chunkString(base10, spec.separator(), spec.span(), false); + return amount(spec, pucks); } - // Split at the 18-digit Gaju <-> Puck boundary - String[] split = splitPucksAndGajus(base10); - String gajuStr = chunkString(split[0], spec.separator(), spec.span(), false); - String puckStr = cleanTrailingZeros(split[1]); + BigInteger[] divRem = absPucks.divideAndRemainder(ONE_GAJU); + String gajuStr = chunkString(divRem[0].toString(), spec.separator(), spec.span(), false); + String sign = isNegative ? "-" : ""; + String head = GAJU_MARK + sign + gajuStr; + + String puckFull = String.format("%018d", divRem[1]); + int prec = Math.min(precision, FRACTION_LEN); + String significant = puckFull.substring(0, prec); + String rest = puckFull.substring(prec); + + boolean hasMore = false; + for (int i = 0; i < rest.length(); i++) { + if (rest.charAt(i) != '0') { + hasMore = true; + break; + } + } + + String resultTail = cleanTrailingZeros(significant); + if (resultTail.isEmpty()) { + return hasMore ? head + "...." : head; + } + + return head + "." + chunkString(resultTail, spec.separator(), spec.span(), true) + (hasMore ? "..." : ""); + } + + private static String formatWestern(FormatSpec spec, BigInteger absPucks, boolean isNegative) { + String sign = isNegative ? "-" : ""; + if (spec.unit() == Unit.PUCK) { + return PUCK_MARK + sign + chunkString(absPucks.toString(), spec.separator(), spec.span(), false); + } + + BigInteger[] divRem = absPucks.divideAndRemainder(ONE_GAJU); + String gajuStr = chunkString(divRem[0].toString(), spec.separator(), spec.span(), false); + String puckStr = String.format("%018d", divRem[1]); + puckStr = cleanTrailingZeros(puckStr); if (puckStr.isEmpty()) { - return GAJU_MARK + gajuStr; + return GAJU_MARK + sign + gajuStr; } - return GAJU_MARK + gajuStr + "." + chunkString(puckStr, spec.separator(), spec.span(), true); + return GAJU_MARK + sign + gajuStr + "." + chunkString(puckStr, spec.separator(), spec.span(), true); } - private static String formatMyriad(FormatSpec spec, String base10, String[] ranks, String negSign) { + private static String formatMyriad(FormatSpec spec, BigInteger absPucks, String[] ranks, boolean isNegative) { + String sign = isNegative ? "-" : ""; if (spec.unit() == Unit.PUCK) { - return processRanks(base10, ranks, 4, PUCK_MARK); + return sign + processRanks(absPucks, ranks, 4, PUCK_MARK, false); } - String[] split = splitPucksAndGajus(base10); - String gajuFormatted = processRanks(split[0], ranks, 4, GAJU_MARK); - String puckClean = cleanTrailingZeros(split[1]); - if (puckClean.isEmpty()) return gajuFormatted; - return gajuFormatted + " " + processRanks(puckClean, ranks, 4, PUCK_MARK); + BigInteger[] divRem = absPucks.divideAndRemainder(ONE_GAJU); + String gajuFormatted = processRanks(divRem[0], ranks, 4, GAJU_MARK, false); + + if (divRem[1].equals(BigInteger.ZERO)) return sign + gajuFormatted; + + return sign + gajuFormatted + " " + processRanks(divRem[1], ranks, 4, PUCK_MARK, false); } - private static String formatBestern(FormatSpec spec, String base10, String[] ranks) { + private static String formatBestern(FormatSpec spec, BigInteger absPucks, String[] ranks, boolean isNegative, String gSuffix, String pSuffix) { + String sign = isNegative ? "-" : ""; if (spec.unit() == Unit.PUCK) { - return PUCK_MARK + processRanks(base10, ranks, 3, "P"); + return PUCK_MARK + sign + processRanks(absPucks, ranks, 3, pSuffix, true); } - String[] split = splitPucksAndGajus(base10); - String gajuPart = GAJU_MARK + processRanks(split[0], ranks, 3, "G"); - String puckClean = cleanTrailingZeros(split[1]); - if (puckClean.isEmpty()) return gajuPart; - return gajuPart + " " + processRanks(puckClean, ranks, 3, "P"); - } - - private static String[] splitPucksAndGajus(String base10) { - if (base10.length() <= FRACTION_LEN) { - char[] zeros = new char[FRACTION_LEN - base10.length()]; - Arrays.fill(zeros, '0'); - return new String[]{"0", new String(zeros) + base10}; - } - int cut = base10.length() - FRACTION_LEN; - return new String[]{base10.substring(0, cut), base10.substring(cut)}; + BigInteger[] divRem = absPucks.divideAndRemainder(ONE_GAJU); + String gajuPart = GAJU_MARK + sign + processRanks(divRem[0], ranks, 3, gSuffix, true); + + if (divRem[1].equals(BigInteger.ZERO)) return gajuPart; + + return gajuPart + " " + processRanks(divRem[1], ranks, 3, pSuffix, true); } private static String cleanTrailingZeros(String str) { int idx = str.length() - 1; - while (idx >= 0 && str.charAt(idx) == '0') idx--; + while (idx >= 0) { + char c = str.charAt(idx); + if (Character.isDigit(c)) { + if (c != '0') break; + } + idx--; + } return idx < 0 ? "" : str.substring(0, idx + 1); } @@ -100,91 +146,135 @@ public final class GajuFormat { return sb.toString(); } - private static String processRanks(String numericStr, String[] ranks, int span, String endingSymbol) { - if (numericStr.equals("0") || numericStr.isEmpty()) return "0 " + endingSymbol; + private static String processRanks(BigInteger value, String[] ranks, int span, String endingSymbol, boolean useSpaces) { + if (value.equals(BigInteger.ZERO)) return "0" + (useSpaces ? " " : "") + endingSymbol; + StringBuilder result = new StringBuilder(); - int len = numericStr.length(); + BigInteger divisor = BigInteger.TEN.pow(span); int rankIndex = 0; - - for (int i = len; i > 0; i -= span) { - int start = Math.max(0, i - span); - String chunk = numericStr.substring(start, i); - long val = Long.parseLong(chunk); + + BigInteger current = value; + while (current.compareTo(BigInteger.ZERO) > 0) { + BigInteger[] dr = current.divideAndRemainder(divisor); + long val = dr[1].longValue(); if (val > 0) { - result.insert(0, val + ranks[rankIndex] + " "); + String rank = ranks[rankIndex]; + result.insert(0, val + rank + (useSpaces ? " " : "")); } + current = dr[0]; rankIndex++; } - return result.toString().trim() + endingSymbol; + + String res = result.toString().trim(); + return res + (useSpaces ? " " : "") + endingSymbol; } - private static final long ONE_GAJU_FACTOR = 1_000_000_000_000_000_000L; - - private static final Map MULTIPLIERS = new HashMap<>(); + private static final Map MULTIPLIERS = new HashMap<>(); static { - // Warhammer 40k heresy meets Base 10k primacy - MULTIPLIERS.put('万', 10_000L); - MULTIPLIERS.put('億', 100_000_000L); - MULTIPLIERS.put('兆', 1_000_000_000_000L); - MULTIPLIERS.put('京', 10_000_000_000_000_000L); - // NOTE: Higher ranges (垓, 秭) require BigInteger parsing if values exceed Long.MAX_VALUE. - // For standard UI entries, Long tracking handles up to 9 Quintillion Pucks + MULTIPLIERS.put('万', BigInteger.valueOf(10_000L)); + MULTIPLIERS.put('億', BigInteger.valueOf(100_000_000L)); + MULTIPLIERS.put('兆', BigInteger.valueOf(1_000_000_000_000L)); + MULTIPLIERS.put('京', BigInteger.valueOf(10_000_000_000_000_000L)); + MULTIPLIERS.put('垓', BigInteger.TEN.pow(20)); + MULTIPLIERS.put('秭', BigInteger.TEN.pow(24)); + MULTIPLIERS.put('穣', BigInteger.TEN.pow(28)); + MULTIPLIERS.put('溝', BigInteger.TEN.pow(32)); + MULTIPLIERS.put('澗', BigInteger.TEN.pow(36)); + MULTIPLIERS.put('正', BigInteger.TEN.pow(40)); + MULTIPLIERS.put('載', BigInteger.TEN.pow(44)); + MULTIPLIERS.put('極', BigInteger.TEN.pow(48)); - // SI / Heresy notations mapping rules - MULTIPLIERS.put('k', 1_000L); - MULTIPLIERS.put('m', 1_000_000L); - MULTIPLIERS.put('G', 1_000_000_000L); - MULTIPLIERS.put('g', 1_000_000_000L); - MULTIPLIERS.put('b', 1_000_000_000L); // Hertical billion flag - MULTIPLIERS.put('T', 1_000_000_000_000L); - MULTIPLIERS.put('t', 1_000_000_000_000L); - MULTIPLIERS.put('P', 1_000_000_000_000_000L); - MULTIPLIERS.put('p', 1_000_000_000_000_000L); - MULTIPLIERS.put('q', 1_000_000_000_000_000L); // Heretical quadrillion flag + MULTIPLIERS.put('k', BigInteger.valueOf(1_000L)); + MULTIPLIERS.put('m', BigInteger.valueOf(1_000_000L)); + MULTIPLIERS.put('g', BigInteger.valueOf(1_000_000_000L)); + MULTIPLIERS.put('b', BigInteger.valueOf(1_000_000_000L)); + MULTIPLIERS.put('t', BigInteger.valueOf(1_000_000_000_000L)); + MULTIPLIERS.put('q', BigInteger.valueOf(1_000_000_000_000_000L)); + MULTIPLIERS.put('p', BigInteger.valueOf(1_000_000_000_000_000L)); + MULTIPLIERS.put('e', BigInteger.TEN.pow(18)); + MULTIPLIERS.put('z', BigInteger.TEN.pow(21)); + MULTIPLIERS.put('y', BigInteger.TEN.pow(24)); + MULTIPLIERS.put('r', BigInteger.TEN.pow(27)); + MULTIPLIERS.put('Q', BigInteger.TEN.pow(30)); } public static byte[] read(String rawInput) { - if (rawInput == null || rawInput.isEmpty()) throw new IllegalArgumentException("Empty string context"); + if (rawInput == null || rawInput.isEmpty()) throw new IllegalArgumentException("Empty input"); + String input = normalize(rawInput); boolean isNegative = false; - String input = rawInput.trim(); - if (input.startsWith("-") || input.startsWith("-") || input.startsWith("-")) { + if (input.startsWith("-") || input.startsWith("-") || input.startsWith("−")) { isNegative = true; input = input.substring(1).trim(); } - boolean forceGaju = input.startsWith("木"); - boolean forcePuck = input.startsWith("本"); + boolean forceGaju = input.startsWith(GAJU_MARK); + boolean forcePuck = input.startsWith(PUCK_MARK); if (forceGaju || forcePuck) { input = input.substring(1).trim(); } - input = input.replace(",", "").replace("_", "").replace(" ", ""); - + BigInteger gajuTotal = BigInteger.ZERO; + BigInteger puckTotal = BigInteger.ZERO; + if (input.contains(".")) { int dotIdx = input.indexOf('.'); - String gajuString = input.substring(0, dotIdx); - String puckString = input.substring(dotIdx + 1); - - StringBuilder sb = new StringBuilder(puckString); - while (sb.length() < 18) sb.append('0'); - if (sb.length() > 18) throw new IllegalArgumentException("Precision overflow error"); - - byte[] gajuBytes = GmMonetaryParser.parseBase10ToBytes(standardizeDigits(gajuString)); - byte[] puckBytes = GmMonetaryParser.parseBase10ToBytes(standardizeDigits(sb.toString())); - - return addAtomicPuckArrays(multiplyByGajuFactor(gajuBytes), puckBytes, isNegative); + String gajuPart = input.substring(0, dotIdx); + String puckPart = input.substring(dotIdx + 1); + + BigInteger gVal = parseRawRanked(gajuPart); + StringBuilder sb = new StringBuilder(puckPart); + while (sb.length() < FRACTION_LEN) sb.append('0'); + if (sb.length() > FRACTION_LEN) throw new IllegalArgumentException("Precision overflow"); + BigInteger pVal = new BigInteger(sb.toString()); + + puckTotal = gVal.multiply(ONE_GAJU).add(pVal); + } else { + boolean treatAsGaju = !forcePuck; + StringBuilder digits = new StringBuilder(); + + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (Character.isDigit(c)) { + digits.append(c); + } else if (c == 'G' || c == '木') { + BigInteger val = digits.length() > 0 ? new BigInteger(digits.toString()) : BigInteger.ZERO; + gajuTotal = gajuTotal.add(val); + digits.setLength(0); + treatAsGaju = false; + } else if (c == 'P' || c == '本') { + BigInteger val = digits.length() > 0 ? new BigInteger(digits.toString()) : BigInteger.ZERO; + puckTotal = puckTotal.add(val); + digits.setLength(0); + break; + } else if (MULTIPLIERS.containsKey(c)) { + if (digits.length() == 0) continue; + BigInteger val = new BigInteger(digits.toString()).multiply(MULTIPLIERS.get(c)); + if (treatAsGaju) gajuTotal = gajuTotal.add(val); + else puckTotal = puckTotal.add(val); + digits.setLength(0); + } + } + if (digits.length() > 0) { + BigInteger val = new BigInteger(digits.toString()); + if (treatAsGaju) gajuTotal = gajuTotal.add(val); + else puckTotal = puckTotal.add(val); + } + puckTotal = gajuTotal.multiply(ONE_GAJU).add(puckTotal); } - return parseRankedString(input, forcePuck, isNegative); + if (isNegative) puckTotal = puckTotal.negate(); + return puckTotal.toByteArray(); } - private static String standardizeDigits(String raw) { + private static String normalize(String raw) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < raw.length(); i++) { char c = raw.charAt(i); if (c >= '0' && c <= '9') { - sb.append((char) (c - '0' + '0')); // full-width -> half-width + sb.append((char) (c - '0' + '0')); + } else if (c == ',' || c == ',' || c == '_' || c == ' ' || c == '\u3000' || c == '\t' || c == '\n' || c == '\r') { + continue; } else { sb.append(c); } @@ -192,51 +282,24 @@ public final class GajuFormat { return sb.toString(); } - private static byte[] parseRankedString(String input, boolean forcePuck, boolean isNegative) { - byte[] totalPucks = new byte[]{0}; + private static BigInteger parseRawRanked(String input) { + BigInteger total = BigInteger.ZERO; StringBuilder currentDigits = new StringBuilder(); - + for (int i = 0; i < input.length(); i++) { char c = input.charAt(i); - char standardC = (c >= '0' && c <= '9') ? (char) (c - '0' + '0') : c; - - if (Character.isDigit(standardC)) { - currentDigits.append(standardC); - } else if (MULTIPLIERS.containsKey(standardC)) { + if (Character.isDigit(c)) { + currentDigits.append(c); + } else if (MULTIPLIERS.containsKey(c)) { if (currentDigits.length() == 0) continue; - - long multiplier = MULTIPLIERS.get(standardC); - long sliceVal = Long.parseLong(currentDigits.toString()) * multiplier; - byte[] blockBytes = GmMonetaryParser.parseBase10ToBytes(Long.toString(sliceVal)); - - // Unless explicitly marked as a standalone Puck entry, assume Gaju - if (!forcePuck && standardC != 'P' && standardC != 'p') { - blockBytes = multiplyByGajuFactor(blockBytes); - } - totalPucks = addAtomicPuckArrays(totalPucks, blockBytes, false); - currentDigits.setLength(0); // Flush buffers! + BigInteger val = new BigInteger(currentDigits.toString()).multiply(MULTIPLIERS.get(c)); + total = total.add(val); + currentDigits.setLength(0); } } - if (currentDigits.length() > 0) { - byte[] remainingBytes = GmMonetaryParser.parseBase10ToBytes(currentDigits.toString()); - if (!forcePuck) remainingBytes = multiplyByGajuFactor(remainingBytes); - totalPucks = addAtomicPuckArrays(totalPucks, remainingBytes, false); + total = total.add(new BigInteger(currentDigits.toString())); } - - return totalPucks; - } - - private static byte[] multiplyByGajuFactor(byte[] base) { - java.math.BigInteger b = new java.math.BigInteger(base); - return b.multiply(java.math.BigInteger.valueOf(ONE_GAJU_FACTOR)).toByteArray(); - } - - private static byte[] addAtomicPuckArrays(byte[] a, byte[] b, boolean makeNegative) { - java.math.BigInteger biA = new java.math.BigInteger(a); - java.math.BigInteger biB = new java.math.BigInteger(b); - java.math.BigInteger sum = biA.add(biB); - if (makeNegative) sum = sum.negate(); - return sum.toByteArray(); + return total; } } diff --git a/gajumaru-core/test/src/gmt.erl b/gajumaru-core/test/src/gmt.erl index e706c6d..6aacce3 100644 --- a/gajumaru-core/test/src/gmt.erl +++ b/gajumaru-core/test/src/gmt.erl @@ -1,5 +1,10 @@ %%% @doc %%% Gajumaru Java Lib Tester: gmt +%%% +%%% This module provides an inter-language test suite for the Gajumaru core +%%% Java libraries. It tests the Java implementation against the canonical +%%% Erlang implementation by generating random test vectors and comparing +%%% the outputs. %%% @end -module(gmt). @@ -11,15 +16,18 @@ -export([start/1]). +%%% Logic + mods() -> - #{"base64" => fun base64/0, - "base58" => fun base58/0, - "rlp" => fun rlp/0}. + #{"base64" => fun base64/0, + "base58" => fun base58/0, + "base58_check" => fun base58_check/0, + "rlp" => fun rlp/0, + "rlp_stream" => fun rlp_stream/0, + "rlp_fail" => fun rlp_fail/0, + "gaju_format" => fun gaju_format/0}. --spec start(ArgV) -> ok - when ArgV :: [string()]. - start([]) -> Tests = mods(), ok = run(Tests), @@ -29,13 +37,14 @@ start(["list"]) -> ok = lists:foreach(fun display/1, maps:keys(mods())), zx:silent_stop(); start(Mods) -> - Tests = maps:with(Mods, mods()), + Available = mods(), + Tests = maps:with(Mods, Available), ok = case maps:size(Tests) =:= length(Mods) of true -> run(Tests); false -> - NotMods = lists:subtract(Mods, maps:keys(Tests)), + NotMods = lists:subtract(Mods, maps:keys(Available)), ok = io:format("The following arguments are not testable module names:~n"), lists:foreach(fun display/1, NotMods) end, @@ -45,8 +54,12 @@ display(Name) -> io:format(" ~ts~n", [Name]). run(Tests) -> - BaseDir = filename:dirname(zx:get_home()), - ok = file:set_cwd(BaseDir), + {ok, Cwd} = file:get_cwd(), + ok = + case filename:basename(Cwd) of + "test" -> file:set_cwd(".."); + _ -> ok + end, ok = clean(), ok = build(), Results = maps:map(fun run/2, Tests), @@ -57,7 +70,7 @@ run(Name, Test) -> Test(). clean() -> - Temp = temp_dir(), + Temp = "test/temp", lists:foreach(fun(D) -> ok = clean(D) end, [Temp]). clean(Dir) -> @@ -72,49 +85,93 @@ build() -> io:format("Compile: ~ts", [Out]). temp_dir() -> - "test/temp". + {ok, Cwd} = file:get_cwd(), + filename:join(Cwd, "test/temp"). + +trim(S) -> + Unprintable = fun(C) -> C =< 32 end, + lists:reverse(lists:dropwhile(Unprintable, lists:reverse(lists:dropwhile(Unprintable, S)))). + + + +%%% Test Modules base64() -> - TestFile = filename:join(temp_dir(), "base64.test"), - ConvFile = filename:join(temp_dir(), "base64.erlang.txt"), + Temp = temp_dir(), + TestFile = filename:join(Temp, "base64.test"), + ConvFile = filename:join(Temp, "base64.erlang.txt"), ok = filelib:ensure_dir(TestFile), ok = file:write_file(TestFile, rand:bytes(rand:uniform(5000))), - {ok, Bytes} = file:read_file(TestFile), - Base64 = base64:encode(Bytes), + {ok, B} = file:read_file(TestFile), + Base64 = base64:encode(B), ok = file:write_file(ConvFile, Base64), - Run = unicode:characters_to_list(["bin/run base64 ", temp_dir()]), - [JavaEncPath, JavaDecPath] = string:split(os:cmd(Run), " "), - {ok, E_Enc_Bytes} = file:read_file(ConvFile), - {ok, J_Enc_Bytes} = file:read_file(JavaEncPath), - E_Hash = crypto:hash(sha512, E_Enc_Bytes), - J_Hash = crypto:hash(sha512, J_Enc_Bytes), - {ok, E_Dec_Bytes} = file:read_file(TestFile), - {ok, J_Dec_Bytes} = file:read_file(JavaDecPath), - E_BinHash = crypto:hash(sha512, E_Dec_Bytes), - J_BinHash = crypto:hash(sha512, J_Dec_Bytes), - E_Hash =:= J_Hash andalso E_BinHash =:= J_BinHash. + Run = "bin/run base64 " ++ Temp, + Out = trim(os:cmd(Run)), + [JEnc, JDec] = string:split(Out, " "), + {ok, EEncB} = file:read_file(ConvFile), + {ok, JEncB} = file:read_file(JEnc), + EHash = crypto:hash(sha512, EEncB), + JHash = crypto:hash(sha512, JEncB), + {ok, EDecB} = file:read_file(TestFile), + {ok, JDecB} = file:read_file(JDec), + EBinHash = crypto:hash(sha512, EDecB), + JBinHash = crypto:hash(sha512, JDecB), + EHash =:= JHash andalso EBinHash =:= JBinHash. + base58() -> - TestFile = filename:join(temp_dir(), "base58.test"), - ConvFile = filename:join(temp_dir(), "base58.erlang.txt"), + Temp = temp_dir(), + TestFile = filename:join(Temp, "base58.test"), + ConvFile = filename:join(Temp, "base58.erlang.txt"), ok = filelib:ensure_dir(TestFile), ok = file:write_file(TestFile, rand:bytes(rand:uniform(5000))), - {ok, Bytes} = file:read_file(TestFile), - Base58 = base58:binary_to_base58(Bytes), + {ok, B} = file:read_file(TestFile), + Base58 = base58:binary_to_base58(B), ok = file:write_file(ConvFile, Base58), - Run = unicode:characters_to_list(["bin/run base58 ", temp_dir()]), - [JavaEncPath, JavaDecPath] = string:split(os:cmd(Run), " "), - {ok, E_Enc_Bytes} = file:read_file(ConvFile), - {ok, J_Enc_Bytes} = file:read_file(JavaEncPath), - E_Hash = crypto:hash(sha512, E_Enc_Bytes), - J_Hash = crypto:hash(sha512, J_Enc_Bytes), - {ok, E_Dec_Bytes} = file:read_file(TestFile), - {ok, J_Dec_Bytes} = file:read_file(JavaDecPath), - E_BinHash = crypto:hash(sha512, E_Dec_Bytes), - J_BinHash = crypto:hash(sha512, J_Dec_Bytes), - E_Hash =:= J_Hash andalso E_BinHash =:= J_BinHash. + Run = "bin/run base58 " ++ Temp, + Out = trim(os:cmd(Run)), + [JEnc, JDec] = string:split(Out, " "), + {ok, EEncB} = file:read_file(ConvFile), + {ok, JEncB} = file:read_file(JEnc), + EHash = crypto:hash(sha512, EEncB), + JHash = crypto:hash(sha512, JEncB), + {ok, EDecB} = file:read_file(TestFile), + {ok, JDecB} = file:read_file(JDec), + EBinHash = crypto:hash(sha512, EDecB), + JBinHash = crypto:hash(sha512, JDecB), + EHash =:= JHash andalso EBinHash =:= JBinHash. + + +%% @doc +%% Tests Base58Check encoding/decoding parity. +%% @end +-spec base58_check() -> boolean(). +base58_check() -> + Temp = temp_dir(), + TestFile = filename:join(Temp, "base58_check.test"), + ConvFile = filename:join(Temp, "base58_check.erlang.txt"), + ok = filelib:ensure_dir(TestFile), + ok = file:write_file(TestFile, rand:bytes(rand:uniform(5000))), + {ok, B} = file:read_file(TestFile), + Checksum = binary:part(crypto:hash(sha256, crypto:hash(sha256, B)), 0, 4), + Base58C = base58:binary_to_base58(<>), + ok = file:write_file(ConvFile, Base58C), + Run = "bin/run base58_check " ++ Temp, + Out = trim(os:cmd(Run)), + [JEnc, JDec] = string:split(Out, " "), + {ok, EEncB} = file:read_file(ConvFile), + {ok, JEncB} = file:read_file(JEnc), + EHash = crypto:hash(sha512, EEncB), + JHash = crypto:hash(sha512, JEncB), + {ok, EDecB} = file:read_file(TestFile), + {ok, JDecB} = file:read_file(JDec), + EBinHash = crypto:hash(sha512, EDecB), + JBinHash = crypto:hash(sha512, JDecB), + EHash =:= JHash andalso EBinHash =:= JBinHash. + rlp() -> + Temp = temp_dir(), Anchor = <<"I thought what I'd do was, I'd pretend I was one of those deaf-mutes.">>, Data = [rand:bytes(rand:uniform(20)), @@ -124,12 +181,147 @@ rlp() -> rand:bytes(rand:uniform(5000))], rand:bytes(rand:uniform(2000))], RLP = gmser_rlp:encode(Data), - RLP_File = filename:join(temp_dir(), "rlp.test"), + RLP_File = filename:join(Temp, "rlp.test"), + ok = filelib:ensure_dir(RLP_File), ok = file:write_file(RLP_File, RLP), - Run = unicode:characters_to_list(["bin/run rlp ", temp_dir()]), - [Found, JavaEncPath] = string:split(os:cmd(Run), " "), - {ok, E_Enc_Bytes} = file:read_file(RLP_File), - {ok, J_Enc_Bytes} = file:read_file(JavaEncPath), - E_Hash = crypto:hash(sha512, E_Enc_Bytes), - J_Hash = crypto:hash(sha512, J_Enc_Bytes), - E_Hash =:= J_Hash andalso Found =:= Anchor. + Run = "bin/run rlp " ++ Temp, + Out = trim(os:cmd(Run)), + case string:split(Out, "|") of + [Found, JPath] -> + JPathTrimmed = trim(JPath), + {ok, EEncB} = file:read_file(RLP_File), + case file:read_file(JPathTrimmed) of + {ok, JEncB} -> + EHash = crypto:hash(sha512, EEncB), + JHash = crypto:hash(sha512, JEncB), + EHash =:= JHash andalso Found =:= unicode:characters_to_list(Anchor); + {error, R} -> + io:format("Failed to read RLP Java result: ~tp (Path: ~tp)~n", [R, JPathTrimmed]), + false + end; + _ -> + io:format("RLP output mismatch: ~tp~n", [Out]), + false + end. + + +%% @doc +%% Tests RLP stream decoding by placing multiple objects in one buffer. +%% @end +-spec rlp_stream() -> boolean(). +rlp_stream() -> + Temp = temp_dir(), + TestFile = filename:join(Temp, "rlp_stream.test"), + ok = filelib:ensure_dir(TestFile), + Data = [rand:bytes(rand:uniform(100)) || _ <- lists:seq(1, 10)], + RLP = << <<(gmser_rlp:encode(D))/binary>> || D <- Data >>, + ok = file:write_file(TestFile, RLP), + Run = "bin/run rlp_stream " ++ Temp, + Out = trim(os:cmd(Run)), + Out =:= "10". + + +rlp_fail() -> + Temp = temp_dir(), + TestFile = filename:join(Temp, "rlp_fail.test"), + ok = filelib:ensure_dir(TestFile), + Data = [<<"item1">>, <<"item2">>], + FullRLP = gmser_rlp:encode(Data), + TruncRLP = binary:part(FullRLP, 0, byte_size(FullRLP) - 2), + ok = file:write_file(TestFile, TruncRLP), + Run = "bin/run rlp_fail " ++ Temp, + Out = trim(os:cmd(Run)), + string:prefix(Out, "RLP_EXCEPTION:") =/= nomatch. + + +gaju_format() -> + Temp = temp_dir(), + TestFile = filename:join(Temp, "gaju_format.test"), + ok = filelib:ensure_dir(TestFile), + Cases = [gen_case() || _ <- lists:seq(1, 100)], + Lines = [serialize_case(C) || C <- Cases], + ok = file:write_file(TestFile, unicode:characters_to_binary([[L, "\n"] || L <- Lines])), + Run = "bin/run gaju_format " ++ Temp, + RawOut = os:cmd(Run), + JavaResPath = trim(RawOut), + case file:read_file(JavaResPath) of + {ok, ResContent} -> + JavaResults = string:split(trim(unicode:characters_to_list(ResContent)), "\n", all), + length(Cases) =:= length(JavaResults) andalso compare_results(Cases, JavaResults); + {error, Reason} -> + io:format("Failed to read Java results from: ~tp (Reason: ~tp)~nRaw Output: ~ts~n", [JavaResPath, Reason, RawOut]), + false + end. + + + +%%% Generatorators + +gen_case() -> + Ops = [amount, approx, read], + Op = lists:nth(rand:uniform(length(Ops)), Ops), + gen_case(Op). + +gen_case(read) -> + Pucks = random_pucks(12), + Style = random_style(), + {read, hz_format:amount(gaju, Style, Pucks), Pucks}; +gen_case(amount) -> + Unit = random_unit(), + Pucks = + case Unit of + gaju -> random_pucks(12); + puck -> random_pucks(8) + end, + Style = random_style(), + Sep = lists:nth(rand:uniform(2), [$,, $_]), + Span = rand:uniform(4), + {amount, Style, Unit, Sep, Span, Pucks, hz_format:amount(Unit, hz_style(Style, Sep, Span), Pucks)}; +gen_case(approx) -> + Pucks = random_pucks(12), + Sep = lists:nth(rand:uniform(2), [$,, $_]), + Span = rand:uniform(2) + 2, + Prec = rand:uniform(18), + {approx, us, gaju, Sep, Span, Pucks, Prec, hz_format:approx_amount({Sep, Span}, Prec, Pucks)}. + +random_pucks(MaxBytes) -> + Bytes = rand:bytes(rand:uniform(MaxBytes)), + crypto:bytes_to_integer(Bytes). + +random_style() -> + lists:nth(rand:uniform(4), [us, jp, metric, legacy]). + +random_unit() -> + lists:nth(rand:uniform(2), [gaju, puck]). + +hz_style(us, Sep, Span) -> {Sep, Span}; +hz_style(Style, _, _) -> Style. + +serialize_case({amount, Style, Unit, Sep, Span, Pucks, _Expected}) -> + io_lib:format("amount|~ts|~ts|~c|~b|~b", [string:uppercase(atom_to_list(Style)), string:uppercase(atom_to_list(Unit)), Sep, Span, Pucks]); +serialize_case({approx, Style, Unit, Sep, Span, Pucks, Prec, _Expected}) -> + io_lib:format("approx|~ts|~ts|~c|~b|~b|~b", [string:uppercase(atom_to_list(Style)), string:uppercase(atom_to_list(Unit)), Sep, Span, Pucks, Prec]); +serialize_case({read, Input, _Expected}) -> + InputStr = unicode:characters_to_list(Input), + io_lib:format("read|~ts", [InputStr]). + +compare_results([], []) -> + true; +compare_results([Case | Cases], [Result | Results]) -> + case check_case(Case, Result) of + true -> + compare_results(Cases, Results); + false -> + io:format("Parity failure!~nCase: ~tp~nJava Result: ~tp~n", [Case, Result]), + false + end. + +check_case({amount, _, _, _, _, _, Expected}, Result) -> + flatten(Expected) =:= flatten(Result); +check_case({approx, _, _, _, _, _, _, Expected}, Result) -> + flatten(Expected) =:= flatten(Result); +check_case({read, _, Expected}, Result) -> + integer_to_list(Expected) =:= flatten(Result). + +flatten(B) when is_binary(B) -> unicode:characters_to_list(B); +flatten(L) when is_list(L) -> unicode:characters_to_list(L).