From 3d3e90a6c628544247e1a26c05dad0a23bdbba65 Mon Sep 17 00:00:00 2001 From: Craig Everett Date: Fri, 21 Aug 2026 14:37:48 +0900 Subject: [PATCH] Lots of stuff... - Finally completed the chain_objects enumeration and general encoding rule - Implemented SpendTx and SignedTx... and I *think* they work now (at least my cheesy canned testing says they do) - Finally decided which way I want to deal with trailing spaces in the advanced variants of GajuFormat - Found a roughly system agnostic way to deal with secret key memory and added an api for using that (might need more, but that will be clear soon) --- .../swiss/qpq/gajumaru/core/crypto/Vault.java | 78 ++++++++++++ .../java/swiss/qpq/gajumaru/core/data/Id.java | 4 +- .../qpq/gajumaru/core/data/SignedTx.java | 69 +++++++++++ .../swiss/qpq/gajumaru/core/data/SpendTx.java | 79 ++++++++++++ .../gajumaru/core/encoding/ApiEncoder.java | 115 +++++++++++++---- .../qpq/gajumaru/core/encoding/Base58.java | 16 +-- .../gajumaru/core/encoding/ChainObjects.java | 117 ++++++++++++++++++ .../gajumaru/core/formatting/GajuFormat.java | 24 ++-- .../qpq/gajumaru/core/tools/CryptoUtils.java | 11 ++ test/Testinator.java | 47 +++++++ test/src/gmt.erl | 73 +++++++++++ 11 files changed, 586 insertions(+), 47 deletions(-) create mode 100644 src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java create mode 100644 src/main/java/swiss/qpq/gajumaru/core/data/SignedTx.java create mode 100644 src/main/java/swiss/qpq/gajumaru/core/data/SpendTx.java create mode 100644 src/main/java/swiss/qpq/gajumaru/core/encoding/ChainObjects.java diff --git a/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java b/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java new file mode 100644 index 0000000..13efbc0 --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/crypto/Vault.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * This program is dual-licensed: + * 1) Under the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version (AGPL-3.0-or-later). + * + * 2) Under a commercial/proprietary license available directly from QPQ AG. + * If you wish to use this software outside the strict constraints of the + * AGPLv3 (e.g. within a closed-source or proprietary product), you must + * purchase a commercial license from QPQ AG. + * + * Authors: + * - Craig Everett + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial + */ + +package swiss.qpq.gajumaru.core.crypto; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + + +// Vault provides secure AES/GCM encryption and decryption with memory hygiene. +// It uses standard JCE providers but ensures that application-level buffers can be wiped. +// +// Purpose: +// This provides a way to use opaque handles to javax.crypto.SecretKey objects in discrete memory +// instead of exposing your plaintext secret keys to other parts of the system that might leave +// dead references on the heap somewhere that are vulnerable until GC finally hits. +// +// The important thing to remember is that there is still a burden on the caller to take advantage +// of whatever the current platform's best key management facilities are and stick to them. + +public final class Vault { + + private static final String AES_GCM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; // Bits + + private Vault() {} + + /** + * Encrypts plaintext using AES/GCM. + * + * @param key The SecretKey to use. + * @param iv The initialization vector (should be 12 bytes). + * @param plaintext The data to encrypt. + * @return The ciphertext including the authentication tag. + * @throws Exception if encryption fails. + */ + public static byte[] encrypt(SecretKey key, byte[] iv, byte[] plaintext) throws Exception { + Cipher cipher = Cipher.getInstance(AES_GCM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, spec); + return cipher.doFinal(plaintext); + } + + /** + * Decrypts ciphertext using AES/GCM. + * + * @param key The SecretKey to use. + * @param iv The initialization vector used during encryption. + * @param ciphertext The data to decrypt (including the authentication tag). + * @return The plaintext data. The caller is RESPONSIBLE for wiping this buffer + * using CryptoUtils.wipe() once it is no longer needed. + * @throws Exception if decryption or authentication fails. + */ + public static byte[] decrypt(SecretKey key, byte[] iv, byte[] ciphertext) throws Exception { + Cipher cipher = Cipher.getInstance(AES_GCM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + return cipher.doFinal(ciphertext); + } +} diff --git a/src/main/java/swiss/qpq/gajumaru/core/data/Id.java b/src/main/java/swiss/qpq/gajumaru/core/data/Id.java index 23fef95..41c8513 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/data/Id.java +++ b/src/main/java/swiss/qpq/gajumaru/core/data/Id.java @@ -90,11 +90,11 @@ public final class Id { return new Id(tag, value); } + // Yes, this is kind of ridiculous, but yay OOP! @Override public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof Id)) return false; - Id id = (Id) o; + if (!(o instanceof Id id)) return false; return tag == id.tag && Arrays.equals(value, id.value); } diff --git a/src/main/java/swiss/qpq/gajumaru/core/data/SignedTx.java b/src/main/java/swiss/qpq/gajumaru/core/data/SignedTx.java new file mode 100644 index 0000000..c2bf33e --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/data/SignedTx.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * This program is dual-licensed: + * 1) Under the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version (AGPL-3.0-or-later). + * + * 2) Under a commercial/proprietary license available directly from QPQ AG. + * If you wish to use this software outside the strict constraints of the + * AGPLv3 (e.g. within a closed-source or proprietary product), you must + * purchase a commercial license from QPQ AG. + * + * Authors: + * - Craig Everett + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial + */ + +package swiss.qpq.gajumaru.core.data; + +import java.util.ArrayList; +import java.util.List; +import swiss.qpq.gajumaru.core.encoding.ChainObjects; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_Data; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_Item; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_List; + +// SignedTx represents a signed Gajumaru TX +// Port of signed_tx in gmser_chain_objects.erl. + +public record SignedTx( + List signatures, + byte[] transaction +) { + + private static final int VSN = 1; + + public byte[] serialize() { + List fields = new ArrayList<>(); + + List sigs = new ArrayList<>(); + for (byte[] sig : signatures) { + sigs.add(new RLP_Item(sig)); + } + fields.add(new RLP_List(sigs)); + fields.add(new RLP_Item(transaction)); + + return ChainObjects.serialize(ChainObjects.TAG_SIGNED_TX, VSN, fields); + } + + public static SignedTx deserialize(byte[] data) { + ChainObjects.SerializationResult res = ChainObjects.deserialize(data); + if (res.tag() != ChainObjects.TAG_SIGNED_TX) { + // Yeah, we can't actually avoid throw. It's disgusting. I don't like it. + throw new IllegalArgumentException("Invalid tag for SignedTx: " + res.tag()); + } + + List f = res.fields(); + + List signatures = new ArrayList<>(); + for (RLP_Data sig : f.get(0).asList().items) { + signatures.add(sig.asItem().bytes); + } + + return new SignedTx(signatures, f.get(1).asItem().bytes); + } +} diff --git a/src/main/java/swiss/qpq/gajumaru/core/data/SpendTx.java b/src/main/java/swiss/qpq/gajumaru/core/data/SpendTx.java new file mode 100644 index 0000000..d5be9e7 --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/data/SpendTx.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * This program is dual-licensed: + * 1) Under the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version (AGPL-3.0-or-later). + * + * 2) Under a commercial/proprietary license available directly from QPQ AG. + * If you wish to use this software outside the strict constraints of the + * AGPLv3 (e.g. within a closed-source or proprietary product), you must + * purchase a commercial license from QPQ AG. + * + * Authors: + * - Craig Everett + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial + */ + +package swiss.qpq.gajumaru.core.data; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import swiss.qpq.gajumaru.core.encoding.ChainObjects; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_Data; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_Item; + + +// As one might imagine, SpendTx represents a Gajumaru SpendTx +// Port of spend_tx in gmser_chain_objects.erl. + +public record SpendTx( + Id senderId, + Id recipientId, + BigInteger amount, + BigInteger gasPrice, + BigInteger gas, + long ttl, + long nonce, + byte[] payload +) { + + private static final int VSN = 1; + + public byte[] serialize() { + List fields = new ArrayList<>(); + fields.add(ChainObjects.encodeId(senderId)); + fields.add(ChainObjects.encodeId(recipientId)); + fields.add(ChainObjects.encodeInt(amount)); + fields.add(ChainObjects.encodeInt(gasPrice)); + fields.add(ChainObjects.encodeInt(gas)); + fields.add(ChainObjects.encodeInt(ttl)); + fields.add(ChainObjects.encodeInt(nonce)); + fields.add(new RLP_Item(payload != null ? payload : new byte[0])); + + return ChainObjects.serialize(ChainObjects.TAG_SPEND_TX, VSN, fields); + } + + public static SpendTx deserialize(byte[] data) { + ChainObjects.SerializationResult res = ChainObjects.deserialize(data); + if (res.tag() != ChainObjects.TAG_SPEND_TX) { + throw new IllegalArgumentException("Invalid tag for SpendTx: " + res.tag()); + } + + List f = res.fields(); + return new SpendTx( + ChainObjects.decodeId(f.get(0)), + ChainObjects.decodeId(f.get(1)), + ChainObjects.decodeBigInt(f.get(2)), + ChainObjects.decodeBigInt(f.get(3)), + ChainObjects.decodeBigInt(f.get(4)), + ChainObjects.decodeLong(f.get(5)), + ChainObjects.decodeLong(f.get(6)), + f.get(7).asItem().bytes + ); + } +} diff --git a/src/main/java/swiss/qpq/gajumaru/core/encoding/ApiEncoder.java b/src/main/java/swiss/qpq/gajumaru/core/encoding/ApiEncoder.java index 35e4628..0c4597d 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/encoding/ApiEncoder.java +++ b/src/main/java/swiss/qpq/gajumaru/core/encoding/ApiEncoder.java @@ -20,37 +20,65 @@ package swiss.qpq.gajumaru.core.encoding; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import java.util.Base64; import java.util.HashMap; import java.util.Map; +import swiss.qpq.gajumaru.core.tools.CryptoUtils; // Port of gmser_api_encoder.erl. -// Handles Gajumaru API encoding (prefixed Base58Check). +// Handles Gajumaru API encoding (prefixed Base58Check or Base64Check). +// +// This module will have to stay in step with gmserialization and will therefore be updated +// from time to time. It is important to keep this as clean as possible for our future selves +// to be able to read and understand. Alignment actually does matter, even if it isn't the +// way "real" Java people do things. public final class ApiEncoder { + public enum Encoding { BASE58, BASE64 } + public enum Type { - ACCOUNT_PUBKEY("ak", 32), - ACCOUNT_SECKEY("sk", 32), - TX_HASH("th", 32), - CONTRACT_PUBKEY("ct", 32), - CHANNEL("ch", 32), - SIGNATURE("sg", 64), - KEY_BLOCK_HASH("kh", 32), - MICRO_BLOCK_HASH("mh", 32), - COMMITMENT("cm", 32), - PEER_PUBKEY("pp", 32), - NAME("nm", -1); // Variable size + KEY_BLOCK_HASH ("kh", 32, Encoding.BASE58), + MICRO_BLOCK_HASH ("mh", 32, Encoding.BASE58), + BLOCK_POF_HASH ("bf", 32, Encoding.BASE58), + BLOCK_TX_HASH ("bx", 32, Encoding.BASE58), + BLOCK_STATE_HASH ("bs", 32, Encoding.BASE58), + BLOCK_WITNESS_HASH ("ws", 32, Encoding.BASE58), + CHANNEL ("ch", 32, Encoding.BASE58), + CONTRACT_PUBKEY ("ct", 32, Encoding.BASE58), + CONTRACT_BYTEARRAY ("cb", -1, Encoding.BASE64), + CONTRACT_STORE_KEY ("ck", -1, Encoding.BASE64), + CONTRACT_STORE_VALUE("cv", -1, Encoding.BASE64), + CONTRACT_SOURCE ("cx", -1, Encoding.BASE64), + TRANSACTION ("tx", -1, Encoding.BASE64), + TX_HASH ("th", 32, Encoding.BASE58), + ACCOUNT_PUBKEY ("ak", 32, Encoding.BASE58), + ACCOUNT_SECKEY ("sk", 32, Encoding.BASE58), + ASSOCIATE_CHAIN ("ac", 32, Encoding.BASE58), + SIGNATURE ("sg", 64, Encoding.BASE58), + COMMITMENT ("cm", 32, Encoding.BASE58), + PEER_PUBKEY ("pp", 32, Encoding.BASE58), + NAME ("nm", -1, Encoding.BASE58), + NATIVE_TOKEN ("nt", 32, Encoding.BASE58), + STATE ("st", 32, Encoding.BASE64), + POI ("pi", -1, Encoding.BASE64), + STATE_TREES ("ss", -1, Encoding.BASE64), + CALL_STATE_TREE ("cs", -1, Encoding.BASE64), + MP_TREE_HASH ("mt", 32, Encoding.BASE58), + HASH ("hs", 32, Encoding.BASE58), + ENTRY ("en", -1, Encoding.BASE64), + BYTEARRAY ("ba", -1, Encoding.BASE64); public final String prefix; public final int size; + public final Encoding encoding; - Type(String prefix, int size) { + Type(String prefix, int size, Encoding encoding) { this.prefix = prefix; this.size = size; + this.encoding = encoding; } } @@ -67,21 +95,38 @@ public final class ApiEncoder { if (type.size != -1 && payload.length != type.size) { throw new IllegalArgumentException("Invalid payload size for " + type + ": " + payload.length); } - return type.prefix + "_" + Base58.checkEncode(payload); + + String encoded; + if (type.encoding == Encoding.BASE58) { + encoded = Base58.checkEncode(payload); + } else { + encoded = base64CheckEncode(payload); + } + + return type.prefix + "_" + encoded; } public static DecodeResult decode(String input) { - String[] parts = input.split("_"); - if (parts.length != 2) { - throw new IllegalArgumentException("Invalid encoded string format"); + int splitIdx = input.indexOf('_'); + if (splitIdx == -1) { + throw new IllegalArgumentException("Invalid encoded string format (missing underscore)"); } - Type type = PREFIX_MAP.get(parts[0]); + String prefix = input.substring(0, splitIdx); + String encoded = input.substring(splitIdx + 1); + + Type type = PREFIX_MAP.get(prefix); if (type == null) { - throw new IllegalArgumentException("Unknown prefix: " + parts[0]); + throw new IllegalArgumentException("Unknown prefix: " + prefix); + } + + byte[] payload; + if (type.encoding == Encoding.BASE58) { + payload = Base58.checkDecode(encoded); + } else { + payload = base64CheckDecode(encoded); } - byte[] payload = Base58.checkDecode(parts[1]); if (type.size != -1 && payload.length != type.size) { throw new IllegalArgumentException("Invalid decoded payload size for " + type + ": " + payload.length); } @@ -89,5 +134,31 @@ public final class ApiEncoder { return new DecodeResult(type, payload); } + private static String base64CheckEncode(byte[] input) { + byte[] checksum = CryptoUtils.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 Base64.getEncoder().encodeToString(combined); + } + + private static byte[] base64CheckDecode(String input) { + byte[] decoded = Base64.getDecoder().decode(input); + if (decoded.length < 4) { + throw new IllegalArgumentException("Base64Check input too short"); + } + + byte[] data = Arrays.copyOfRange(decoded, 0, decoded.length - 4); + byte[] actual = Arrays.copyOfRange(decoded, decoded.length - 4, decoded.length); + byte[] expected = CryptoUtils.doubleSha256(data); + + for (int i = 0; i < 4; i++) { + if (actual[i] != expected[i]) { + throw new IllegalArgumentException("Base64Check checksum mismatch"); + } + } + return data; + } + public record DecodeResult(Type type, byte[] payload) {} } diff --git a/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java b/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java index ed08705..3cc9872 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java +++ b/src/main/java/swiss/qpq/gajumaru/core/encoding/Base58.java @@ -20,9 +20,8 @@ package swiss.qpq.gajumaru.core.encoding; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import swiss.qpq.gajumaru.core.tools.CryptoUtils; // Stateless Base58 and Base58Check implementation. @@ -145,7 +144,7 @@ public final class Base58 { //Encodes bytes with a 4-byte double-SHA256 checksum. public static String checkEncode(byte[] input) { - byte[] checksum = doubleSha256(input); + byte[] checksum = CryptoUtils.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); @@ -161,7 +160,7 @@ public final class Base58 { byte[] data = Arrays.copyOfRange(decoded, 0, decoded.length - 4); byte[] actual = Arrays.copyOfRange(decoded, decoded.length - 4, decoded.length); - byte[] expected = doubleSha256(data); + byte[] expected = CryptoUtils.doubleSha256(data); for (int i = 0; i < 4; i++) { if (actual[i] != expected[i]) { @@ -173,13 +172,4 @@ public final class Base58 { // 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/src/main/java/swiss/qpq/gajumaru/core/encoding/ChainObjects.java b/src/main/java/swiss/qpq/gajumaru/core/encoding/ChainObjects.java new file mode 100644 index 0000000..80897a4 --- /dev/null +++ b/src/main/java/swiss/qpq/gajumaru/core/encoding/ChainObjects.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * This program is dual-licensed: + * 1) Under the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version (AGPL-3.0-or-later). + * + * 2) Under a commercial/proprietary license available directly from QPQ AG. + * If you wish to use this software outside the strict constraints of the + * AGPLv3 (e.g. within a closed-source or proprietary product), you must + * purchase a commercial license from QPQ AG. + * + * Authors: + * - Craig Everett + * + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial + */ + +package swiss.qpq.gajumaru.core.encoding; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import swiss.qpq.gajumaru.core.data.Id; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_Data; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_Item; +import swiss.qpq.gajumaru.core.encoding.RLP.RLP_List; + +// ChainObjects provides tagged serialization for Gajumaru objects. +// Port of gmser_chain_objects.erl and gmserialization.erl. +// +// NOTE: Craig 2026-08-21 +// It may be possible to consolidate this a bit further. Something that has always bothered me +// about the Gajumaru support libs is how much jumping between modules is often required. I'll +// eventually restructure that to be a bit more simple, but if we can start these libs off in +// that direction, that would be a tiny win, especially given how opaque Java can be. That +// opaqueness is also wy I'm staying away from class hierarchies here as much as possible. + +public final class ChainObjects { + + public static final int TAG_ACCOUNT = 10; + public static final int TAG_SIGNED_TX = 11; + public static final int TAG_SPEND_TX = 12; + public static final int TAG_CONTRACT_CREATE_TX = 42; + public static final int TAG_CONTRACT_CALL_TX = 43; + public static final int TAG_ORACLE_REGISTER_TX = 22; // Check exact value + // ... add more as needed from gmser_chain_objects.erl + + private ChainObjects() {} + + public static byte[] serialize(int tag, int vsn, List fields) { + List fullList = new ArrayList<>(); + fullList.add(encodeInt(tag)); + fullList.add(encodeInt(vsn)); + fullList.addAll(fields); + return RLP.encode(new RLP_List(fullList)); + } + + public static SerializationResult deserialize(byte[] data) { + RLP_Data rlp = RLP.decode(data); + if (!(rlp instanceof RLP_List list)) { + throw new RLP.RLPException("Expected RLP list for ChainObject"); + } + + List items = list.items; + if (items.size() < 2) { + throw new RLP.RLPException("ChainObject list too short (missing tag/vsn)"); + } + + int tag = decodeInt(items.get(0)); + int vsn = decodeInt(items.get(1)); + List fields = items.subList(2, items.size()); + + return new SerializationResult(tag, vsn, fields); + } + + public static RLP_Item encodeId(Id id) { + return new RLP_Item(id.serialize()); + } + + public static Id decodeId(RLP_Data data) { + return Id.deserialize(data.asItem().bytes); + } + + public record SerializationResult(int tag, int vsn, List fields) {} + + // Helper to encode integers for RLP (big-endian, no leading zeros) + public static RLP_Item encodeInt(long val) { + return encodeInt(BigInteger.valueOf(val)); + } + + public static RLP_Item encodeInt(BigInteger val) { + if (val.equals(BigInteger.ZERO)) return new RLP_Item(new byte[0]); + byte[] bytes = val.toByteArray(); + // Remove leading zero byte if present (BigInteger adds one if top bit is set because reasons) + if (bytes.length > 1 && bytes[0] == 0) { + bytes = java.util.Arrays.copyOfRange(bytes, 1, bytes.length); + } + return new RLP_Item(bytes); + } + + public static int decodeInt(RLP_Data data) { + return decodeBigInt(data).intValue(); + } + + public static long decodeLong(RLP_Data data) { + return decodeBigInt(data).longValue(); + } + + public static BigInteger decodeBigInt(RLP_Data data) { + byte[] bytes = data.asItem().bytes; + if (bytes.length == 0) return BigInteger.ZERO; + return new BigInteger(1, bytes); + } +} diff --git a/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java b/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java index f17c6bb..ebadf64 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java +++ b/src/main/java/swiss/qpq/gajumaru/core/formatting/GajuFormat.java @@ -21,14 +21,18 @@ package swiss.qpq.gajumaru.core.formatting; import java.math.BigInteger; -import java.util.Arrays; import java.util.HashMap; +import java.util.Locale; 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. +// +// NOTE: Craig 2026-08-21 +// This module will give you inane warnings in an IDE if you use it as a submodule. +// Don't worry about them. They are just noise. public final class GajuFormat { public enum Type { US, JP, METRIC, LEGACY } @@ -43,8 +47,8 @@ public final class GajuFormat { 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) { return amount(spec, new BigInteger(puckBytes)); @@ -75,7 +79,7 @@ public final class GajuFormat { String sign = isNegative ? "-" : ""; String head = GAJU_MARK + sign + gajuStr; - String puckFull = String.format("%018d", divRem[1]); + String puckFull = String.format(Locale.US, "%018d", divRem[1]); int prec = Math.min(precision, FRACTION_LEN); String significant = puckFull.substring(0, prec); String rest = puckFull.substring(prec); @@ -104,7 +108,7 @@ public final class GajuFormat { BigInteger[] divRem = absPucks.divideAndRemainder(ONE_GAJU); String gajuStr = chunkString(divRem[0].toString(), spec.separator(), spec.span(), false); - String puckStr = String.format("%018d", divRem[1]); + String puckStr = String.format(Locale.US, "%018d", divRem[1]); puckStr = cleanTrailingZeros(puckStr); if (puckStr.isEmpty()) { @@ -122,7 +126,7 @@ public final class GajuFormat { 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; + if (divRem[1].equals(BigInteger.ZERO)) return sign + gajuFormatted + " "; return sign + gajuFormatted + " " + processRanks(divRem[1], ranks, 4, PUCK_MARK, false); } @@ -136,7 +140,7 @@ public final class GajuFormat { 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; + if (divRem[1].equals(BigInteger.ZERO)) return gajuPart + " "; return gajuPart + " " + processRanks(divRem[1], ranks, 3, pSuffix, true); } @@ -179,7 +183,7 @@ public final class GajuFormat { long val = dr[1].longValue(); if (val > 0) { String rank = ranks[rankIndex]; - result.insert(0, val + rank + (useSpaces ? " " : "")); + result.insert(0, val + rank); } current = dr[0]; rankIndex++; @@ -258,12 +262,12 @@ public final class GajuFormat { if (Character.isDigit(c)) { digits.append(c); } else if (c == 'G' || c == '木') { - BigInteger val = digits.length() > 0 ? new BigInteger(digits.toString()) : BigInteger.ZERO; + 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; + BigInteger val = (digits.length() > 0) ? new BigInteger(digits.toString()) : BigInteger.ZERO; puckTotal = puckTotal.add(val); digits.setLength(0); break; diff --git a/src/main/java/swiss/qpq/gajumaru/core/tools/CryptoUtils.java b/src/main/java/swiss/qpq/gajumaru/core/tools/CryptoUtils.java index fcdeebd..7163d5d 100644 --- a/src/main/java/swiss/qpq/gajumaru/core/tools/CryptoUtils.java +++ b/src/main/java/swiss/qpq/gajumaru/core/tools/CryptoUtils.java @@ -20,6 +20,8 @@ package swiss.qpq.gajumaru.core.tools; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Arrays; @@ -42,6 +44,15 @@ public final class CryptoUtils { } } + public 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); + } + } + public static String binToHex(byte[] data) { StringBuilder sb = new StringBuilder(); for (byte b : data) { diff --git a/test/Testinator.java b/test/Testinator.java index ef25b6c..1fb9c4e 100644 --- a/test/Testinator.java +++ b/test/Testinator.java @@ -15,6 +15,8 @@ import swiss.qpq.gajumaru.core.crypto.Keccak256; import swiss.qpq.gajumaru.core.crypto.Blake2b; import swiss.qpq.gajumaru.core.crypto.Ed25519; import swiss.qpq.gajumaru.core.data.Id; +import swiss.qpq.gajumaru.core.data.SpendTx; +import swiss.qpq.gajumaru.core.data.SignedTx; import swiss.qpq.gajumaru.core.tools.CryptoUtils; public class Testinator { @@ -39,6 +41,8 @@ public class Testinator { case "ed25519_verify" -> { System.out.print(ed25519_verify(args[1])); } case "api_encode" -> { System.out.print(api_encode(args[1])); } case "id_serialization" -> { System.out.print(id_serialization(args[1])); } + case "spend_tx" -> { System.out.print(spend_tx(args[1])); } + case "signed_tx" -> { System.out.print(signed_tx(args[1])); } case "fe_parity" -> { System.out.print(fe_parity(args[1])); } case "reduce_parity" -> { System.out.print(reduce_parity(args[1])); } case "smb_parity" -> { System.out.print(smb_parity(args[1])); } @@ -413,4 +417,47 @@ public class Testinator { Files.write(resPath, results); return resPath.toString(); } + + private static String spend_tx(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "spend_tx.test"); + Path resPath = Path.of(workingPath, "spend_tx.java.txt"); + List lines = Files.readAllLines(testPath); + List results = new ArrayList<>(); + for (String line : lines) { + if (line.trim().isEmpty()) continue; + String[] p = line.split("\\|"); + SpendTx tx = new SpendTx( + Id.deserialize(CryptoUtils.hexToBin(p[0])), + Id.deserialize(CryptoUtils.hexToBin(p[1])), + new BigInteger(p[2]), + new BigInteger(p[3]), + new BigInteger(p[4]), + Long.parseLong(p[5]), + Long.parseLong(p[6]), + CryptoUtils.hexToBin(p[7]) + ); + results.add(CryptoUtils.binToHex(tx.serialize())); + } + Files.write(resPath, results); + return resPath.toString(); + } + + private static String signed_tx(String workingPath) throws IOException { + Path testPath = Path.of(workingPath, "signed_tx.test"); + Path resPath = Path.of(workingPath, "signed_tx.java.txt"); + List lines = Files.readAllLines(testPath); + List results = new ArrayList<>(); + for (String line : lines) { + if (line.trim().isEmpty()) continue; + String[] p = line.split("\\|"); + List sigs = new ArrayList<>(); + for (String s : p[0].split(",")) { + sigs.add(CryptoUtils.hexToBin(s)); + } + SignedTx tx = new SignedTx(sigs, CryptoUtils.hexToBin(p[1])); + results.add(CryptoUtils.binToHex(tx.serialize())); + } + Files.write(resPath, results); + return resPath.toString(); + } } diff --git a/test/src/gmt.erl b/test/src/gmt.erl index cd5d9e6..3da79f9 100644 --- a/test/src/gmt.erl +++ b/test/src/gmt.erl @@ -31,6 +31,8 @@ mods() -> "ed25519" => fun ed25519/0, "api_encode" => fun api_encode/0, "id_serialization" => fun id_serialization/0, + "spend_tx" => fun spend_tx/0, + "signed_tx" => fun signed_tx/0, "fe_parity" => fun fe_parity/0, "reduce_parity" => fun reduce_parity/0, "smb_parity" => fun smb_parity/0, @@ -347,6 +349,77 @@ id_serialization() -> same_same(Expected, JResults). +spend_tx() -> + Temp = temp_dir(), + TestFile = filename:join(Temp, "spend_tx.test"), + Cases = [gen_spend_tx() || _ <- lists:seq(1, 20)], + Lines = [serialize_spend_tx(C) || C <- Cases], + ok = filelib:ensure_dir(TestFile), + ok = file:write_file(TestFile, unicode:characters_to_binary([[L, "\n"] || L <- Lines])), + Expected = [bin_to_hex(gmser_chain_objects:serialize(spend_tx, 1, spend_tx_template(), C)) || C <- Cases], + Run = "bin/run spend_tx " ++ Temp, + Out = trim(os:cmd(Run)), + {ok, JOutContent} = file:read_file(trim(Out)), + JResults = string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all), + same_same(Expected, JResults). + +spend_tx_template() -> + [{sender_id, id}, + {recipient_id, id}, + {amount, int}, + {gas_price, int}, + {gas, int}, + {ttl, int}, + {nonce, int}, + {payload, binary}]. + +gen_spend_tx() -> + [{sender_id, gmser_id:create(account, rand:bytes(32))}, + {recipient_id, gmser_id:create(account, rand:bytes(32))}, + {amount, random_pucks(12)}, + {gas_price, 1000000000}, + {gas, 20000}, + {ttl, rand:uniform(1000000)}, + {nonce, rand:uniform(10000)}, + {payload, rand:bytes(rand:uniform(100))}]. + +serialize_spend_tx(Fields) -> + ID = fun(Key) -> bin_to_hex(gmser_id:encode(proplists:get_value(Key, Fields))) end, + Int = fun(Key) -> integer_to_list(proplists:get_value(Key, Fields)) end, + Bin = fun(Key) -> bin_to_hex(proplists:get_value(Key, Fields)) end, + Parts = [ID(sender_id), ID(recipient_id), Int(amount), Int(gas_price), Int(gas), Int(ttl), Int(nonce), Bin(payload)], + string:join(Parts, "|"). + + +signed_tx() -> + Temp = temp_dir(), + TestFile = filename:join(Temp, "signed_tx.test"), + Cases = [gen_signed_tx() || _ <- lists:seq(1, 20)], + Lines = [serialize_signed_tx(C) || C <- Cases], + ok = filelib:ensure_dir(TestFile), + ok = file:write_file(TestFile, unicode:characters_to_binary([[L, "\n"] || L <- Lines])), + Expected = [bin_to_hex(gmser_chain_objects:serialize(signed_tx, 1, signed_tx_template(), C)) || C <- Cases], + Run = "bin/run signed_tx " ++ Temp, + Out = trim(os:cmd(Run)), + {ok, JOutContent} = file:read_file(trim(Out)), + JResults = string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all), + same_same(Expected, JResults). + +signed_tx_template() -> + [{signatures, [binary]}, + {transaction, binary}]. + +gen_signed_tx() -> + [{signatures, [rand:bytes(64) || _ <- lists:seq(1, rand:uniform(3))]}, + {transaction, rand:bytes(rand:uniform(500))}]. + +serialize_signed_tx(Fields) -> + Sigs = proplists:get_value(signatures, Fields), + SigsHex = string:join([bin_to_hex(S) || S <- Sigs], ","), + TxHex = bin_to_hex(proplists:get_value(transaction, Fields)), + SigsHex ++ "|" ++ TxHex. + + fe_parity() -> Temp = temp_dir(), TestFile = filename:join(Temp, "fe.test"),