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)
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
|
||||
* Project: Gajumaru Core Java Libraries <gajumaru.io>
|
||||
*
|
||||
* 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 <craigeverett@qpq.swiss>
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
|
||||
* Project: Gajumaru Core Java Libraries <gajumaru.io>
|
||||
*
|
||||
* 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 <craigeverett@qpq.swiss>
|
||||
*
|
||||
* 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<byte[]> signatures,
|
||||
byte[] transaction
|
||||
) {
|
||||
|
||||
private static final int VSN = 1;
|
||||
|
||||
public byte[] serialize() {
|
||||
List<RLP_Data> fields = new ArrayList<>();
|
||||
|
||||
List<RLP_Data> 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<RLP_Data> f = res.fields();
|
||||
|
||||
List<byte[]> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
|
||||
* Project: Gajumaru Core Java Libraries <gajumaru.io>
|
||||
*
|
||||
* 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 <craigeverett@qpq.swiss>
|
||||
*
|
||||
* 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<RLP_Data> 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<RLP_Data> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
|
||||
* Project: Gajumaru Core Java Libraries <gajumaru.io>
|
||||
*
|
||||
* 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 <craigeverett@qpq.swiss>
|
||||
*
|
||||
* 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<RLP_Data> fields) {
|
||||
List<RLP_Data> 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<RLP_Data> 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<RLP_Data> 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<RLP_Data> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String> lines = Files.readAllLines(testPath);
|
||||
List<String> 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<String> lines = Files.readAllLines(testPath);
|
||||
List<String> results = new ArrayList<>();
|
||||
for (String line : lines) {
|
||||
if (line.trim().isEmpty()) continue;
|
||||
String[] p = line.split("\\|");
|
||||
List<byte[]> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user