Compare commits

...
30 Commits
Author SHA1 Message Date
zxq9 381c8f1b9a Cleanup 2026-08-30 17:45:26 +09:00
zxq9 6714353f8c WIP 2026-08-26 19:13:49 +09:00
zxq9 45c6f9c730 WIP: Default to 0 amounts 2026-08-26 18:51:58 +09:00
zxq9 7cd299d250 WIP: Alternative route. Still need better conformance to libsodium's Ed25519 API, but almost there. 2026-08-26 18:18:46 +09:00
zxq9 9287cbb4b7 WIP: GCM IV length silliness... maybe. 2026-08-26 14:52:16 +09:00
zxq9 64c592285a WIP: Lots of networking niggly bits and JNI stuff and uuugh 2026-08-25 20:52:43 +09:00
zxq9 954d8760ac Typo 2026-08-23 18:24:38 +09:00
zxq9 20e462ec03 WIP 2026-08-23 18:16:17 +09:00
zxq9 11516b1cca WIP 2026-08-23 16:13:48 +09:00
zxq9 c1c13213c1 WIP: Fix lingering S < L issue 2026-08-23 11:38:14 +09:00
zxq9 5407431566 WIP: Add note about memory safety 2026-08-23 11:17:12 +09:00
zxq9 ea7a013abe WIP: Tiny note 2026-08-23 11:03:00 +09:00
zxq9 ddc811b63d WIP: fixed Character.isDigit() issue by making my own isDigit() 2026-08-23 10:50:04 +09:00
zxq9 c21f399b69 WIP 2026-08-23 10:41:49 +09:00
zxq9 97bb833d35 WIP: Notes 2026-08-23 10:19:42 +09:00
zxq9 77dea4f3cf WIP 2026-08-23 10:15:59 +09:00
zxq9 7493b473a7 I hate computers now and the letter "a" has stopped working without mashing it 2026-08-21 17:22:30 +09:00
zxq9 3d3e90a6c6 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)
2026-08-21 14:37:48 +09:00
zxq9 a4ca21593b Add SUPERCOP to references 2026-08-21 10:35:17 +09:00
zxq9 b751b46edc Add references 2026-08-21 10:31:57 +09:00
zxq9 b95ba8a88f Adding TODO note to Ed25519 2026-08-21 10:18:50 +09:00
zxq9 1de3608c9b WIP 2026-08-21 08:42:43 +09:00
zxq9 4019af58e9 Serialization and Ed25519 (#2)
finally...

Reviewed-on: #2
Co-authored-by: Craig Everett <zxq9@zxq9.com>
Co-committed-by: Craig Everett <zxq9@zxq9.com>
2026-08-20 21:19:16 +09:00
zxq9 ea90d9d3ab WIP 2026-08-12 18:24:12 +09:00
zxq9 78b5fb1512 Lawyer stuff 2026-08-12 18:03:58 +09:00
zxq9 f99e081160 WIP 2026-08-12 18:02:28 +09:00
zxq9 11cdfc4569 WIP 2026-08-12 17:59:35 +09:00
zxq9 8cce885721 WIP 2026-08-12 17:56:21 +09:00
zxq9 8cff34abb4 WIP 2026-08-12 17:24:13 +09:00
zxq9 366c6157af WIP: still need to test against hz_format 2026-07-28 15:58:06 +09:00
40 changed files with 6066 additions and 434 deletions
+3
View File
@@ -6,9 +6,12 @@ Thumbs.db
.netrwhist
.nvimlog
.idea/
.artifacts/
*.iml
.gradle/
local.properties
Captures/
.externalNativeBuild/
temp
erl_crash.dump
*.class
+26
View File
@@ -2,6 +2,32 @@
Java libraries for the Gajumaru
## Purpose
gm-java provides the basic functionality required to interact with the [Gajumaru](https://gajumaru./io)
blockchain system. This includes serialization, function wrappers for Gajumaru node endpoint calls,
handling of GRIDS URLs, formatting of monetary values, any cryptographic functions not covered in
standard Java libraries, and some network functionality necessary to interact smoothly with
Gajumaru chains (Groot and AC networks).
This codebase may *not* include references to third-party libraries, may *not* permit un-zeroed
byte values to be left on the heap until the JVM eventually decides to garbage collect them,
and may *not* build up a big, gnarly, inheritance-heavy OOPsy object hierarchy.
Classes should generally be structured as `final` classes of functions that operate over data
that can be expressed as Java primitives, most commonly `byte[]` and some form of `int` or
occasionally `BigInteger`.
`BigInteger` is not permitted in cryptographic operations, as it leaves potentially sensitive
artifacts scattered all throughout the heap and can occasionally make the garbage collector
go bananas when performing heavy math operations. `BigInteger` is excellent, however, for things
like representing currency values in Pucks.
All forms of floating-point arithmetic are forbidden in all currency calculations. The Gajumaru
does not have floats, and they do not exist at all in the Sophia language.
## Terms
Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
+2 -3
View File
@@ -14,11 +14,10 @@ rm -f "$project_dir/Testinator.class"
# Build every .java file
find "$project_dir/src/main/java" -name "*.java" | xargs javac \
-source 21 \
-target 21 \
--release 21 \
-d "$project_dir/build/classes"
# Build the Testinator thingy
javac -cp "build/classes" -d build src/Testinator.java
javac -cp "build/classes" -d build test/Testinator.java
echo "Bytecode in $project_dir/build/classes/"
View File
-69
View File
@@ -1,69 +0,0 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import swiss.qpq.gajumaru.core.encoding.Base58;
public class Testinator {
public static void main(String[] args) {
if (args.length != 2) {
System.out.println("Error: Provide the test suite name and the working dir path.");
System.exit(1);
}
try {
switch (args[0]) {
case "base64" -> {
System.out.print(base64(args[1]));
}
case "base58" -> {
System.out.print(base58(args[1]));
}
case "rlp" -> {
System.out.print(rlp(args[1]));
}
}
} catch (IOException e) {
System.err.println("Error: " + e.getMessage());
System.exit(1);
}
}
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);
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);
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),
Files.write(decPath, decBytes);
return encPath.toString() + " " + decPath.toString();
}
}
@@ -1,191 +0,0 @@
/*
* 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.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class RLP {
// Integrated data models
// 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 final class RLP_Item extends RLP_Data {
public final byte[] bytes;
public RLP_Item(byte[] bytes) {
this.bytes = bytes != null ? bytes : new byte[0];
}
}
public static final class RLP_List extends RLP_Data {
public final List<RLP_Data> items;
public RLP_List(List<RLP_Data> items) {
this.items = items != null ? items : new ArrayList<>();
}
}
private RLP() {}
public static byte[] encode(RLP_Data data) {
if (data instanceof RLP_Item item) {
return encodeItem(item.bytes);
} else if (data instanceof RLP_List list) {
return encodeList(list.items);
}
throw new IllegalArgumentException("Unsupported RLP type");
}
private static byte[] encodeItem(byte[] bytes) {
if (bytes.length == 1 && (bytes[0] & 0xFF) <= 0x7F) {
return bytes;
}
return prefixData(bytes, 0x80, 0xB7);
}
private static byte[] encodeList(List<RLP_Data> items) {
if (items.isEmpty()) {
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;
for (int i = 0; i < items.size(); i++) {
encodedChildren[i] = encode(items.get(i));
totalPayloadLength += 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];
System.arraycopy(prefix, 0, result, 0, prefix.length);
int writePtr = prefix.length;
for (byte[] child : encodedChildren) {
System.arraycopy(child, 0, result, writePtr, child.length);
writePtr += child.length;
}
return result;
}
private static byte[] prefixData(byte[] payload, int shortOffset, int longOffset) {
byte[] prefix = prefixLength(payload.length, shortOffset, longOffset);
byte[] result = new byte[prefix.length + payload.length];
System.arraycopy(prefix, 0, result, 0, prefix.length);
System.arraycopy(payload, 0, result, prefix.length, payload.length);
return result;
}
private static byte[] prefixLength(int length, int shortOffset, int longOffset) {
if (length <= 55) {
return new byte[] { (byte) (shortOffset + length) };
}
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;
}
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];
for (int i = size - 1; i >= 0; i--) {
sigBytes[i] = (byte) (val & 0xFF);
val >>>= 8;
}
return sigBytes;
}
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) {
List<RLP_Data> 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;
}
return new RLP_List(elements);
}
private static int bigEndianToInt(byte[] bytes, int start, int end) {
int result = 0;
for (int i = start; i < end; i++) {
result = (result << 8) | (bytes[i] & 0xFF);
}
return result;
}
}
-15
View File
@@ -1,15 +0,0 @@
# GM Java <-> Erlang Tests
The core Java libraries are all written as transform functions over data.
There is little point, therefore, in obsessing over "unit tests" and "regression testing" of Java code in Java when we have cannonical code in Erlang.
The purpose of this utility is to instead test the Java libraries agains the Erlang libraries directly.
## How to run
In the current directory simply run `zx runlocal` and the report map will be printed to the screen and random test data will be in the `temp/` directory.
To run a specific test suite run `zx runlocal [module names]`.
To list module names, run `zx runlocal list`.
-135
View File
@@ -1,135 +0,0 @@
%%% @doc
%%% Gajumaru Java Lib Tester: gmt
%%% @end
-module(gmt).
-vsn("0.1.0").
-author("Craig Everett <craigeverett@qpq.swiss>").
-copyright("Craig Everett <craigeverett@qpq.swiss>").
-license("LGPL-3.0-or-later").
-export([start/1]).
mods() ->
#{"base64" => fun base64/0,
"base58" => fun base58/0,
"rlp" => fun rlp/0}.
-spec start(ArgV) -> ok
when ArgV :: [string()].
start([]) ->
Tests = mods(),
ok = run(Tests),
zx:silent_stop();
start(["list"]) ->
ok = io:format("Available tests:~n"),
ok = lists:foreach(fun display/1, maps:keys(mods())),
zx:silent_stop();
start(Mods) ->
Tests = maps:with(Mods, mods()),
ok =
case maps:size(Tests) =:= length(Mods) of
true ->
run(Tests);
false ->
NotMods = lists:subtract(Mods, maps:keys(Tests)),
ok = io:format("The following arguments are not testable module names:~n"),
lists:foreach(fun display/1, NotMods)
end,
zx:silent_stop().
display(Name) ->
io:format(" ~ts~n", [Name]).
run(Tests) ->
BaseDir = filename:dirname(zx:get_home()),
ok = file:set_cwd(BaseDir),
ok = clean(),
ok = build(),
Results = maps:map(fun run/2, Tests),
io:format("Results:~n ~tp~n", [Results]).
run(Name, Test) ->
ok = io:format("~nRunning: ~ts...~n", [Name]),
Test().
clean() ->
Temp = temp_dir(),
lists:foreach(fun(D) -> ok = clean(D) end, [Temp]).
clean(Dir) ->
case file:del_dir_r(Dir) of
ok -> ok;
{error, enoent} -> ok;
Error -> Error
end.
build() ->
Out = os:cmd("bin/compile"),
io:format("Compile: ~ts", [Out]).
temp_dir() ->
"test/temp".
base64() ->
TestFile = filename:join(temp_dir(), "base64.test"),
ConvFile = filename:join(temp_dir(), "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 = 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.
base58() ->
TestFile = filename:join(temp_dir(), "base58.test"),
ConvFile = filename:join(temp_dir(), "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 = 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.
rlp() ->
Anchor = <<"I thought what I'd do was, I'd pretend I was one of those deaf-mutes.">>,
Data =
[rand:bytes(rand:uniform(20)),
[rand:bytes(rand:uniform(20)),
Anchor,
rand:bytes(rand:uniform(20)),
rand:bytes(rand:uniform(5000))],
rand:bytes(rand:uniform(2000))],
RLP = gmser_rlp:encode(Data),
RLP_File = filename:join(temp_dir(), "rlp.test"),
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.
@@ -0,0 +1,155 @@
/*
* 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 java.util.Arrays;
// Pure Java implementation of BLAKE2b.
// Based on eblake2.erl.
public final class Blake2b {
private static final long[] IV = {
0x6a09e667f3bcc908L, 0xbb67ae8584caa73bL, 0x3c6ef372fe94f82bL, 0xa54ff53a5f1d36f1L,
0x510e527fade682d1L, 0x9b05688c2b3e6c1fL, 0x1f83d9abfb41bd6bL, 0x5be0cd19137e2179L
};
private static final byte[][] SIGMA = {
{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 },
{ 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3 },
{ 11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4 },
{ 7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8 },
{ 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13 },
{ 2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9 },
{ 12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11 },
{ 13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10 },
{ 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5 },
{ 10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0 },
{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }, // Round 10: sigma[0]
{ 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3 } // Round 11: sigma[1]
};
private Blake2b() {}
public static byte[] hash(byte[] data) {
return hash(data, 32);
}
public static byte[] hash(byte[] data, int hashLen) {
long[] h = Arrays.copyOf(IV, 8);
h[0] ^= 0x01010000L ^ hashLen;
long t0 = 0;
long t1 = 0;
int offset = 0;
while (offset + 128 < data.length) {
t0 += 128;
if (t0 < 128) t1++; // Overflow
compress(h, data, offset, t0, t1, false);
offset += 128;
}
t0 += (data.length - offset);
if (t0 < (data.length - offset)) t1++;
byte[] lastBlock = new byte[128];
System.arraycopy(data, offset, lastBlock, 0, data.length - offset);
compress(h, lastBlock, 0, t0, t1, true);
byte[] fullHash = new byte[64];
for (int i = 0; i < 8; i++) {
writeLongLittleEndian(h[i], fullHash, i * 8);
}
byte[] result = Arrays.copyOf(fullHash, hashLen);
// Memory hygiene
Arrays.fill(h, 0L);
Arrays.fill(fullHash, (byte) 0);
Arrays.fill(lastBlock, (byte) 0);
return result;
}
private static void compress(long[] h, byte[] block, int offset, long t0, long t1, boolean isLast) {
long[] m = new long[16];
for (int i = 0; i < 16; i++) {
m[i] = readLongLittleEndian(block, offset + i * 8);
}
long[] v = new long[16];
System.arraycopy( h, 0, v, 0, 8);
System.arraycopy(IV, 0, v, 8, 8);
v[12] ^= t0;
v[13] ^= t1;
if (isLast) {
v[14] ^= 0xffffffffffffffffL;
}
for (int round = 0; round < 12; round++) {
byte[] s = SIGMA[round];
g(v, 0, 4, 8, 12, m[s[0]], m[s[1]]);
g(v, 1, 5, 9, 13, m[s[2]], m[s[3]]);
g(v, 2, 6, 10, 14, m[s[4]], m[s[5]]);
g(v, 3, 7, 11, 15, m[s[6]], m[s[7]]);
g(v, 0, 5, 10, 15, m[s[8]], m[s[9]]);
g(v, 1, 6, 11, 12, m[s[10]], m[s[11]]);
g(v, 2, 7, 8, 13, m[s[12]], m[s[13]]);
g(v, 3, 4, 9, 14, m[s[14]], m[s[15]]);
}
for (int i = 0; i < 8; i++) {
h[i] ^= v[i] ^ v[i + 8];
}
// Wiping local arrays
Arrays.fill(m, 0L);
Arrays.fill(v, 0L);
}
private static void g(long[] v, int a, int b, int c, int d, long x, long y) {
v[a] = v[a] + v[b] + x;
v[d] = Long.rotateRight(v[d] ^ v[a], 32);
v[c] = v[c] + v[d];
v[b] = Long.rotateRight(v[b] ^ v[c], 24);
v[a] = v[a] + v[b] + y;
v[d] = Long.rotateRight(v[d] ^ v[a], 16);
v[c] = v[c] + v[d];
v[b] = Long.rotateRight(v[b] ^ v[c], 63);
}
private static long readLongLittleEndian(byte[] b, int offset) {
long res = 0;
for (int i = 0; i < 8; i++) {
res |= ((long) (b[offset + i] & 0xFF)) << (i * 8);
}
return res;
}
private static void writeLongLittleEndian(long v, byte[] b, int offset) {
for (int i = 0; i < 8; i++) {
b[offset + i] = (byte) ((v >>> (i * 8)) & 0xFF);
}
}
}
@@ -0,0 +1,948 @@
/*
* 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 java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import swiss.qpq.gajumaru.core.tools.CryptoUtils;
// Pure Java implementation of Ed25519 curve arithmetic.
// Uses exquisitely annoying Radix-2^25.5 field arithmetic. Never do this if you can avoid it.
// Verified against the canonical Erlang ec_utils (see test/README.md for how).
//
// SAFETY NOTE:
// Despite the effort at zeroing sensitive memory in this module, Java heap memory does not
// provide reliable secret-memory erasure or even really permit it. If protection against
// copies of secret material in managed memory is a requirement of the deployment environment,
// don't use this implementation; use explicitly managed native memory instead.
//
// This implementation is intended for isolated environments where the Java heap is an
// acceptable trust boundary. It is not intended to provide secure-memory guarantees
// against a hostile or inspecting runtime.
//
// Android is one such platform where a vendor-provided C implementation of Ed25519
// interacting over a JNI boundary with off-heap memory (via java.nio.ByteBuffer)
// and aggressive memory sanitation is necessary.
//
// WARNING:
// Do NOT compile this code with compiler optimizations turned on. It will remove the memory
// protection functionality entirely as it is "no impact" code from the perspective of
// performance optimization. Configure your build system to deliberately turn off optimizing
// profilers for this module. You will need to do the same for any C code you might interface
// with over JNI as well. Constant time execution, memory wiping, etc. are all viewed as
// no-impact busywork or pessimization from the perspective of a performance profiler.
//
// References:
// I don't even know where to start with references, but the tink-java library and pretty much
// everything (and everyone!) referenced on the lib25519 page deserves a mention.
// Of special mention, of course, is SUPERCOP.
//
// tink-java: https://github.com/tink-crypto/tink-java
// lib25519 : https://lib25519.cr.yp.to/people.html
// SUPERCOP : https://bench.cr.yp.to/supercop.html
// TODO: Craig 2026-08-21
// I don't like the allocation of Scratch and Ge all over the place.
// If someone were to apply this library to a high-throughput system, with many threads
// signing stuff willy-nilly, then intense GC pressure could result simply because of
// all the dead (and zeroed) Scratch and Ge space left littered throughout the dead heap
// awaiting GC. What I want to do instead is provide a separate call path that allows the
// current mechanism to work as well as a slightly lower-level call path where the caller
// can provide a pre-allocated space by reference so if a high-throughput system is using
// lots of worker threads and really pressuring the system, the caller can pre-allocate the
// needed GC and Scratch space themselves once per thread.
//
// This should be pretty easy.
// I just don't want to look at this module for at least a few days.
public final class Ed25519 {
// Precomputed limbs for Ed25519 constants (Radix 2^25.5)
private static final long[] BX = {52811034L, 25909283L, 16144682L, 17082669L, 27570973L, 30858332L, 40966398L, 8378388L, 20764389L, 8758491L};
private static final long[] BY = {40265304L, 26843545L, 13421772L, 20132659L, 26843545L, 6710886L, 53687091L, 13421772L, 40265318L, 26843545L};
private static final long[] D = {56195235L, 13857412L, 51736253L, 6949390L, 114729L, 24766616L, 60832955L, 30306712L, 48412415L, 21499315L};
private static final long[] D2 = {45281625L, 27714825L, 36363642L, 13898781L, 229458L, 15978800L, 54557047L, 27058993L, 29715967L, 9444199L};
private static final long[] I = {34513072L, 25610706L, 9377949L, 3500415L, 12389472L, 33281959L, 41962654L, 31548777L, 326685L, 11406482L};
// Group order L (little-endian)
private static final byte[] L = {
(byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c, (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58,
(byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2, (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10
};
// Field prime P = 2^255 - 19 (little-endian, 255 bits used)
private static final byte[] P = {
(byte) 0xed, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff,
(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff,
(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff,
(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x7f
};
public static final class Ge {
public final long[] X = new long[10], Y = new long[10], Z = new long[10], T = new long[10];
// Internal: Wipes the coordinates.
public void wipe() {
CryptoUtils.wipe(X);
CryptoUtils.wipe(Y);
CryptoUtils.wipe(Z);
CryptoUtils.wipe(T);
}
}
public static final class Scratch {
public final long[]
a = new long[10], b = new long[10], c = new long[10], d = new long[10],
e = new long[10], f = new long[10], g = new long[10], h = new long[10],
tmp = new long[10], t19 = new long[19];
public final long[][] stack = new long[10][10];
public final Ge geTmp = new Ge();
// Internal: Wipes the scratch space.
public void wipe() {
CryptoUtils.wipe(a);
CryptoUtils.wipe(b);
CryptoUtils.wipe(c);
CryptoUtils.wipe(d);
CryptoUtils.wipe(e);
CryptoUtils.wipe(f);
CryptoUtils.wipe(g);
CryptoUtils.wipe(h);
CryptoUtils.wipe(tmp);
CryptoUtils.wipe(t19);
for (long[] s : stack) CryptoUtils.wipe(s);
geTmp.wipe();
}
}
private Ed25519() {
}
public static byte[] publicKey(byte[] seed) {
byte[] hash = sha512(seed);
byte[] s = Arrays.copyOfRange(hash, 0, 32);
s[0] &= 248;
s[31] &= 127;
s[31] |= 64;
Scratch sc = new Scratch();
Ge A_point = scalarMulBase(s, sc);
byte[] A = compress(A_point, sc);
A_point.wipe();
sc.wipe();
CryptoUtils.wipe(hash);
CryptoUtils.wipe(s);
return A;
}
public static byte[] sign(byte[] seed, byte[] message) {
byte[] hash = sha512(seed);
byte[] s = Arrays.copyOfRange(hash, 0, 32);
s[0] &= 248;
s[31] &= 127;
s[31] |= 64;
byte[] prefix = Arrays.copyOfRange(hash, 32, 64);
byte[] rHash = sha512(prefix, message);
reduceScalar(rHash);
byte[] r = Arrays.copyOfRange(rHash, 0, 32);
Scratch sc = new Scratch();
Ge R_point = scalarMulBase(r, sc);
byte[] R = compress(R_point, sc);
byte[] A = publicKey(seed);
byte[] kHash = sha512(R, A, message);
reduceScalar(kHash);
byte[] k = Arrays.copyOfRange(kHash, 0, 32);
byte[] S = new byte[32];
scalarMulAdd(S, k, s, r);
byte[] sig = new byte[64];
System.arraycopy(R, 0, sig, 0, 32);
System.arraycopy(S, 0, sig, 32, 32);
R_point.wipe();
sc.wipe();
CryptoUtils.wipe(hash);
CryptoUtils.wipe(s);
CryptoUtils.wipe(prefix);
CryptoUtils.wipe(rHash);
CryptoUtils.wipe(r);
CryptoUtils.wipe(kHash);
CryptoUtils.wipe(k);
CryptoUtils.wipe(A);
return sig;
}
public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) {
if (signature.length != 64) return false;
byte[] R_bytes = Arrays.copyOfRange(signature, 0, 32);
byte[] S_bytes = Arrays.copyOfRange(signature, 32, 64);
if (!isLessThan(S_bytes, L)) return false;
Scratch sc = new Scratch();
Ge A = decompress(publicKey, sc);
if (A == null) return false;
Ge R = decompress(R_bytes, sc);
if (R == null) return false;
byte[] kHash = sha512(R_bytes, publicKey, message);
reduceScalar(kHash);
byte[] k = Arrays.copyOfRange(kHash, 0, 32);
Ge sB = scalarMulBase(S_bytes, sc);
Ge kA = scalarMul(A, k, sc);
Ge RHS = new Ge();
ge_add(RHS, R, kA, sc);
byte[] LHS_bytes = compress(sB, sc);
byte[] RHS_bytes = compress(RHS, sc);
boolean ok = Arrays.equals(LHS_bytes, RHS_bytes);
A.wipe();
R.wipe();
sB.wipe();
kA.wipe();
RHS.wipe();
sc.wipe();
CryptoUtils.wipe(kHash);
CryptoUtils.wipe(k);
return ok;
}
/**
* Signs a message using a 64-byte secret key (seed | public_key).
* This matches the libsodium behavior.
*/
public static byte[] signWithExpandedKey(byte[] message, byte[] sk64) {
byte[] seed = new byte[32];
System.arraycopy(sk64, 0, seed, 0, 32);
try {
return sign(seed, message);
} finally {
CryptoUtils.wipe(seed);
}
}
// NOTE:
// Internal curve and field arithmetic machinery.
// These are public only for parity testing against reference implementations.
// Internal: Scalar multiplication by base point.
public static Ge scalarMulBase(byte[] scalar, Scratch s) {
Ge res = new Ge();
fe_0(res.X);
fe_1(res.Y);
fe_1(res.Z);
fe_0(res.T);
Ge q = new Ge();
fe_copy(q.X, BX);
fe_copy(q.Y, BY);
fe_1(q.Z);
fe_mul(q.T, BX, BY, s.t19);
for (int i = 0; i < 256; i++) {
int bit = ((scalar[i / 8] & 0xFF) >>> (i % 8)) & 1;
ge_add(s.geTmp, res, q, s);
ge_cmov(res, s.geTmp, bit);
ge_double(q, q, s);
}
q.wipe();
return res;
}
// Internal: General scalar multiplication.
public static Ge scalarMul(Ge p, byte[] scalar, Scratch s) {
Ge res = new Ge();
fe_0(res.X);
fe_1(res.Y);
fe_1(res.Z);
fe_0(res.T);
Ge q = new Ge();
fe_copy(q.X, p.X);
fe_copy(q.Y, p.Y);
fe_copy(q.Z, p.Z);
fe_copy(q.T, p.T);
for (int i = 0; i < 256; i++) {
int bit = ((scalar[i / 8] & 0xFF) >>> (i % 8)) & 1;
ge_add(s.geTmp, res, q, s);
ge_cmov(res, s.geTmp, bit);
ge_double(q, q, s);
}
q.wipe();
return res;
}
// Internal: Point addition.
public static void ge_add(Ge r, Ge p1, Ge p2, Scratch s) {
long[] yMinusX1 = s.stack[0], yPlusX1 = s.stack[1], yMinusX2 = s.stack[2], yPlusX2 = s.stack[3];
long[] A = s.stack[4], B = s.stack[5], C = s.stack[6], D = s.stack[7];
long[] E = s.stack[8], F = s.stack[9], G = s.a, H = s.b;
fe_sub(yMinusX1, p1.Y, p1.X);
fe_add(yPlusX1, p1.Y, p1.X);
fe_sub(yMinusX2, p2.Y, p2.X);
fe_add(yPlusX2, p2.Y, p2.X);
fe_mul(A, yMinusX1, yMinusX2, s.t19);
fe_mul(B, yPlusX1, yPlusX2, s.t19);
fe_mul(C, p1.T, p2.T, s.t19);
fe_mul(C, C, D2, s.t19);
fe_mul(D, p1.Z, p2.Z, s.t19);
fe_add(D, D, D);
fe_sub(E, B, A);
fe_sub(F, D, C);
fe_add(G, D, C);
fe_add(H, B, A);
fe_mul(r.X, E, F, s.t19);
fe_mul(r.Y, G, H, s.t19);
fe_mul(r.Z, F, G, s.t19);
fe_mul(r.T, E, H, s.t19);
}
// Internal: Point doubling.
public static void ge_double(Ge r, Ge p, Scratch s) {
long[] A = s.stack[0], B = s.stack[1], C = s.stack[2], D = s.stack[3];
long[] E = s.stack[4], F = s.stack[5], G = s.stack[6], H = s.stack[7];
fe_sq(A, p.X, s.t19);
fe_sq(B, p.Y, s.t19);
fe_sq(C, p.Z, s.t19);
fe_add(C, C, C);
fe_neg(D, A);
fe_add(E, p.X, p.Y);
fe_sq(E, E, s.t19);
fe_sub(E, E, A);
fe_sub(E, E, B);
fe_add(G, D, B);
fe_sub(F, G, C);
fe_sub(H, D, B);
fe_mul(r.X, E, F, s.t19);
fe_mul(r.Y, G, H, s.t19);
fe_mul(r.Z, F, G, s.t19);
fe_mul(r.T, E, H, s.t19);
}
private static void ge_cmov(Ge r, Ge p, int b) {
fe_cmov(r.X, p.X, b);
fe_cmov(r.Y, p.Y, b);
fe_cmov(r.Z, p.Z, b);
fe_cmov(r.T, p.T, b);
}
private static void fe_cmov(long[] r, long[] p, int b) {
long mask = -(long) b;
for (int i = 0; i < 10; i++) {
long x = mask & (r[i] ^ p[i]);
r[i] ^= x;
}
}
// Internal: Point decompression.
public static Ge decompress(byte[] b, Scratch s) {
if (b.length != 32) return null;
// Ensure y is canonical (y < P)
byte[] y_check = Arrays.copyOf(b, 32);
y_check[31] &= 0x7F;
if (!isLessThan(y_check, P)) return null;
Ge p = new Ge();
fe_frombytes(p.Y, b);
fe_1(p.Z);
// u = y^2 - 1
fe_sq(s.a, p.Y, s.t19);
fe_sub(s.a, s.a, p.Z);
// v = dy^2 + 1
fe_sq(s.b, p.Y, s.t19);
fe_mul(s.b, s.b, D, s.t19);
fe_add(s.b, s.b, p.Z);
// check root of u/v
fe_invert(s.c, s.b, s);
fe_mul(s.c, s.a, s.c, s.t19); // x2 = u/v
fe_pow22523(s.a, s.c, s); // a = x2^((p-5)/8)
fe_mul(s.a, s.c, s.a, s.t19); // x = x2 * a = x2^((p+3)/8)
fe_sq(s.b, s.a, s.t19);
fe_sub(s.b, s.b, s.c); // x^2 - x2
if (fe_isnonzero(s.b)) {
fe_mul(s.a, s.a, I, s.t19);
fe_sq(s.b, s.a, s.t19);
fe_sub(s.b, s.b, s.c);
if (fe_isnonzero(s.b)) return null;
}
if (fe_isnegative(s.a) != ((b[31] >> 7) & 1)) fe_neg(p.X, s.a);
else fe_copy(p.X, s.a);
fe_mul(p.T, p.X, p.Y, s.t19);
return p;
}
// Internal: Point compression.
public static byte[] compress(Ge p, Scratch s) {
fe_invert(s.a, p.Z, s);
fe_mul(s.b, p.X, s.a, s.t19);
fe_mul(s.c, p.Y, s.a, s.t19);
byte[] out = fe_contract(s.c);
if (fe_isnegative(s.b) == 1) out[31] |= (byte) 0x80;
return out;
}
private static void fe_0(long[] h) {
for (int i = 0; i < 10; i++) h[i] = 0;
}
private static void fe_1(long[] h) {
h[0] = 1;
for (int i = 1; i < 10; i++) h[i] = 0;
}
private static void fe_copy(long[] h, long[] f) {
System.arraycopy(f, 0, h, 0, 10);
}
private static void fe_add(long[] h, long[] f, long[] g) {
for (int i = 0; i < 10; i++) h[i] = f[i] + g[i];
}
private static void fe_sub(long[] h, long[] f, long[] g) {
for (int i = 0; i < 10; i++) h[i] = f[i] - g[i];
}
private static void fe_neg(long[] h, long[] f) {
for (int i = 0; i < 10; i++) h[i] = -f[i];
}
private static boolean isLessThan(byte[] a, byte[] b) {
for (int i = 31; i >= 0; i--) {
int ai = a[i] & 0xff;
int bi = b[i] & 0xff;
if (ai < bi) return true;
if (ai > bi) return false;
}
return false;
}
// Internal: Field multiplication.
public static void fe_mul(long[] out, long[] f, long[] g, long[] t) {
t[0] = f[0] * g[0];
t[1] = f[0] * g[1] + f[1] * g[0];
t[2] = f[0] * g[2] + f[2] * g[0] + 2 * f[1] * g[1];
t[3] = f[0] * g[3] + f[3] * g[0] + f[1] * g[2] + f[2] * g[1];
t[4] = f[0] * g[4] + f[4] * g[0] + f[2] * g[2] + 2 * f[1] * g[3] + 2 * f[3] * g[1];
t[5] = f[0] * g[5] + f[5] * g[0] + f[1] * g[4] + f[4] * g[1] + f[2] * g[3] + f[3] * g[2];
t[6] = f[0] * g[6] + f[6] * g[0] + f[2] * g[4] + f[4] * g[2] + 2 * f[1] * g[5] + 2 * f[5] * g[1] + 2 * f[3] * g[3];
t[7] = f[0] * g[7] + f[7] * g[0] + f[1] * g[6] + f[6] * g[1] + f[2] * g[5] + f[5] * g[2] + f[3] * g[4] + f[4] * g[3];
t[8] = f[0] * g[8] + f[8] * g[0] + f[2] * g[6] + f[6] * g[2] + f[4] * g[4] + 2 * f[1] * g[7] + 2 * f[7] * g[1] + 2 * f[3] * g[5] + 2 * f[5] * g[3];
t[9] = f[0] * g[9] + f[9] * g[0] + f[1] * g[8] + f[8] * g[1] + f[2] * g[7] + f[7] * g[2] + f[3] * g[6] + f[6] * g[3] + f[4] * g[5] + f[5] * g[4];
t[10] = 2 * f[1] * g[9] + 2 * f[9] * g[1] + f[2] * g[8] + f[8] * g[2] + 2 * f[3] * g[7] + 2 * f[7] * g[3] + f[4] * g[6] + f[6] * g[4] + 2 * f[5] * g[5];
t[11] = f[2] * g[9] + f[9] * g[2] + f[3] * g[8] + f[8] * g[3] + f[4] * g[7] + f[7] * g[4] + f[5] * g[6] + f[6] * g[5];
t[12] = 2 * f[3] * g[9] + 2 * f[9] * g[3] + f[4] * g[8] + f[8] * g[4] + 2 * f[5] * g[7] + 2 * f[7] * g[5] + f[6] * g[6];
t[13] = f[4] * g[9] + f[9] * g[4] + f[5] * g[8] + f[8] * g[5] + f[6] * g[7] + f[7] * g[6];
t[14] = 2 * f[5] * g[9] + 2 * f[9] * g[5] + f[6] * g[8] + f[8] * g[6] + 2 * f[7] * g[7];
t[15] = f[6] * g[9] + f[9] * g[6] + f[7] * g[8] + f[8] * g[7];
t[16] = 2 * f[7] * g[9] + 2 * f[9] * g[7] + f[8] * g[8];
t[17] = f[8] * g[9] + f[9] * g[8];
t[18] = 2 * f[9] * g[9];
fe_reduce(out, t);
}
/// Internal: Field squaring.
public static void fe_sq(long[] out, long[] f, long[] t) {
t[0] = f[0] * f[0];
t[1] = 2 * f[0] * f[1];
t[2] = 2 * f[0] * f[2] + 2 * f[1] * f[1];
t[3] = 2 * f[0] * f[3] + 2 * f[1] * f[2];
t[4] = 2 * f[0] * f[4] + 4 * f[1] * f[3] + f[2] * f[2];
t[5] = 2 * f[0] * f[5] + 2 * f[1] * f[4] + 2 * f[2] * f[3];
t[6] = 2 * f[0] * f[6] + 4 * f[1] * f[5] + 2 * f[2] * f[4] + 2 * f[3] * f[3];
t[7] = 2 * f[0] * f[7] + 2 * f[1] * f[6] + 2 * f[2] * f[5] + 2 * f[3] * f[4];
t[8] = 2 * f[0] * f[8] + 4 * f[1] * f[7] + 2 * f[2] * f[6] + 4 * f[3] * f[5] + f[4] * f[4];
t[9] = 2 * f[0] * f[9] + 2 * f[1] * f[8] + 2 * f[2] * f[7] + 2 * f[3] * f[6] + 2 * f[4] * f[5];
t[10] = 4 * f[1] * f[9] + 2 * f[2] * f[8] + 4 * f[3] * f[7] + 2 * f[4] * f[6] + 2 * f[5] * f[5];
t[11] = 2 * f[2] * f[9] + 2 * f[3] * f[8] + 2 * f[4] * f[7] + 2 * f[5] * f[6];
t[12] = 4 * f[3] * f[9] + 2 * f[4] * f[8] + 4 * f[5] * f[7] + f[6] * f[6];
t[13] = 2 * f[4] * f[9] + 2 * f[5] * f[8] + 2 * f[6] * f[7];
t[14] = 4 * f[5] * f[9] + 2 * f[6] * f[8] + 2 * f[7] * f[7];
t[15] = 2 * f[6] * f[9] + 2 * f[7] * f[8];
t[16] = 4 * f[7] * f[9] + f[8] * f[8];
t[17] = 2 * f[8] * f[9];
t[18] = 2 * f[9] * f[9];
fe_reduce(out, t);
}
// Internal: Field reduction.
public static void fe_reduce(long[] h, long[] t) {
t[0] += t[10] * 19;
t[1] += t[11] * 19;
t[2] += t[12] * 19;
t[3] += t[13] * 19;
t[4] += t[14] * 19;
t[5] += t[15] * 19;
t[6] += t[16] * 19;
t[7] += t[17] * 19;
t[8] += t[18] * 19;
for (int p = 0; p < 2; p++) {
for (int i = 0; i < 9; i++) {
long c = t[i] >> (i % 2 == 0 ? 26 : 25);
t[i] &= (i % 2 == 0 ? 0x3FFFFFFL : 0x1FFFFFFL);
t[i + 1] += c;
}
long c = t[9] >> 25;
t[9] &= 0x1FFFFFFL;
t[0] += c * 19;
}
for (int i = 0; i < 10; i++) h[i] = t[i];
}
// Internal: Load field element from bytes.
public static void fe_frombytes(long[] h, byte[] s) {
for (int i = 0; i < 10; i++) h[i] = 0;
int bitIdx = 0;
for (int i = 0; i < 10; i++) {
int len = (i % 2 == 0 ? 26 : 25);
for (int b = 0; b < len; b++) {
if (bitIdx < 255) {
if ((((s[bitIdx / 8] & 0xFF) >> (bitIdx % 8)) & 1) == 1) h[i] |= (1L << b);
}
bitIdx++;
}
}
}
// Internal: Contract field element to bytes.
public static byte[] fe_contract(long[] h) {
long[] val = Arrays.copyOf(h, 10);
for (int p = 0; p < 2; p++) {
for (int i = 0; i < 9; i++) {
long c = val[i] >> (i % 2 == 0 ? 26 : 25);
val[i] &= (i % 2 == 0 ? 0x3FFFFFFL : 0x1FFFFFFL);
val[i + 1] += c;
}
long c = val[9] >> 25;
val[9] &= 0x1FFFFFFL;
val[0] += c * 19;
}
long mask = 1;
for (int i = 9; i >= 0; i--) {
long target = (i % 2 == 0 ? 0x3FFFFFFL : 0x1FFFFFFL);
if (i == 0) target -= 19;
if (val[i] < target) {
mask = 0;
break;
}
if (val[i] > target) break;
}
val[0] += mask * 19;
for (int i = 0; i < 9; i++) {
long c = val[i] >> (i % 2 == 0 ? 26 : 25);
val[i] &= (i % 2 == 0 ? 0x3FFFFFFL : 0x1FFFFFFL);
val[i + 1] += c;
}
val[9] -= mask * (1L << 25);
for (int i = 0; i < 9; i++) {
long c = val[i] >> (i % 2 == 0 ? 26 : 25);
val[i] &= (i % 2 == 0 ? 0x3FFFFFFL : 0x1FFFFFFL);
val[i + 1] += c;
}
val[0] += (val[9] >> 25) * 19;
val[9] &= 0x1FFFFFFL;
byte[] out = new byte[32];
int bitIdx = 0;
for (int i = 0; i < 10; i++) {
int len = (i % 2 == 0 ? 26 : 25);
for (int bit = 0; bit < len; bit++) {
if (bitIdx < 255) {
if (((val[i] >> bit) & 1) == 1) out[bitIdx / 8] |= (byte) (1 << (bitIdx % 8));
bitIdx++;
}
}
}
return out;
}
private static void fe_invert(long[] out, long[] z, Scratch s) {
long[] t0 = s.stack[0], t1 = s.stack[1], z2 = s.stack[2], z9 = s.stack[3], z11 = s.stack[4];
long[] z2_5_0 = s.stack[5], z2_10_0 = s.stack[6], z2_20_0 = s.stack[7], z2_50_0 = s.stack[8], z2_100_0 = s.stack[9];
fe_sq(z2, z, s.t19);
fe_sq(t1, z2, s.t19);
fe_sq(t0, t1, s.t19);
fe_mul(z9, t0, z, s.t19);
fe_mul(z11, z9, z2, s.t19);
fe_sq(t0, z11, s.t19);
fe_mul(z2_5_0, t0, z9, s.t19);
fe_sq(t0, z2_5_0, s.t19);
for (int i = 1; i < 5; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_10_0, t0, z2_5_0, s.t19);
fe_sq(t0, z2_10_0, s.t19);
for (int i = 1; i < 10; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_20_0, t0, z2_10_0, s.t19);
fe_sq(t0, z2_20_0, s.t19);
for (int i = 1; i < 20; i++) fe_sq(t0, t0, s.t19);
fe_mul(t0, t0, z2_20_0, s.t19);
fe_sq(t0, t0, s.t19);
for (int i = 1; i < 10; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_50_0, t0, z2_10_0, s.t19);
fe_sq(t0, z2_50_0, s.t19);
for (int i = 1; i < 50; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_100_0, t0, z2_50_0, s.t19);
fe_sq(t1, z2_100_0, s.t19);
for (int i = 1; i < 100; i++) fe_sq(t1, t1, s.t19);
fe_mul(t1, t1, z2_100_0, s.t19);
fe_sq(t0, t1, s.t19);
for (int i = 1; i < 50; i++) fe_sq(t0, t0, s.t19);
fe_mul(t0, t0, z2_50_0, s.t19);
fe_sq(t1, t0, s.t19);
for (int i = 1; i < 5; i++) fe_sq(t1, t1, s.t19);
fe_mul(out, t1, z11, s.t19);
}
private static void fe_pow22523(long[] out, long[] in, Scratch s) {
long[] t0 = s.stack[0], t1 = s.stack[1], z2 = s.stack[2], z9 = s.stack[3], z11 = s.stack[4];
long[] z2_5_0 = s.stack[5], z2_10_0 = s.stack[6], z2_20_0 = s.stack[7], z2_50_0 = s.stack[8], z2_100_0 = s.stack[9];
fe_sq(z2, in, s.t19);
fe_sq(t1, z2, s.t19);
fe_sq(t0, t1, s.t19);
fe_mul(z9, t0, in, s.t19);
fe_mul(z11, z9, z2, s.t19);
fe_sq(t0, z11, s.t19);
fe_mul(z2_5_0, t0, z9, s.t19);
fe_sq(t0, z2_5_0, s.t19);
for (int i = 1; i < 5; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_10_0, t0, z2_5_0, s.t19);
fe_sq(t0, z2_10_0, s.t19);
for (int i = 1; i < 10; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_20_0, t0, z2_10_0, s.t19);
fe_sq(t0, z2_20_0, s.t19);
for (int i = 1; i < 20; i++) fe_sq(t0, t0, s.t19);
fe_mul(t0, t0, z2_20_0, s.t19);
fe_sq(t0, t0, s.t19);
for (int i = 1; i < 10; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_50_0, t0, z2_10_0, s.t19);
fe_sq(t0, z2_50_0, s.t19);
for (int i = 1; i < 50; i++) fe_sq(t0, t0, s.t19);
fe_mul(z2_100_0, t0, z2_50_0, s.t19);
fe_sq(t1, z2_100_0, s.t19);
for (int i = 1; i < 100; i++) fe_sq(t1, t1, s.t19);
fe_mul(t1, t1, z2_100_0, s.t19);
fe_sq(t0, t1, s.t19);
for (int i = 1; i < 50; i++) fe_sq(t0, t0, s.t19);
fe_mul(t0, t0, z2_50_0, s.t19);
fe_sq(t0, t0, s.t19);
fe_sq(t0, t0, s.t19);
fe_mul(out, t0, in, s.t19);
}
private static boolean fe_isnonzero(long[] h) {
byte[] s = fe_contract(h);
for (byte b : s) if (b != 0) return true;
return false;
}
private static int fe_isnegative(long[] h) {
byte[] s = fe_contract(h);
return s[0] & 1;
}
// Internal: Scalar reduction.
public static void reduceScalar(byte[] s) {
long s0 = 2097151 & load3(s, 0);
long s1 = 2097151 & (load4(s, 2) >> 5);
long s2 = 2097151 & (load3(s, 5) >> 2);
long s3 = 2097151 & (load4(s, 7) >> 7);
long s4 = 2097151 & (load4(s, 10) >> 4);
long s5 = 2097151 & (load3(s, 13) >> 1);
long s6 = 2097151 & (load4(s, 15) >> 6);
long s7 = 2097151 & (load3(s, 18) >> 3);
long s8 = 2097151 & load3(s, 21);
long s9 = 2097151 & (load4(s, 23) >> 5);
long s10 = 2097151 & (load3(s, 26) >> 2);
long s11 = 2097151 & (load4(s, 28) >> 7);
long s12 = 2097151 & (load4(s, 31) >> 4);
long s13 = 2097151 & (load3(s, 34) >> 1);
long s14 = 2097151 & (load4(s, 36) >> 6);
long s15 = 2097151 & (load3(s, 39) >> 3);
long s16 = 2097151 & load3(s, 42);
long s17 = 2097151 & (load4(s, 44) >> 5);
long s18 = 2097151 & (load3(s, 47) >> 2);
long s19 = 2097151 & (load4(s, 49) >> 7);
long s20 = 2097151 & (load4(s, 52) >> 4);
long s21 = 2097151 & (load3(s, 55) >> 1);
long s22 = 2097151 & (load4(s, 57) >> 6);
long s23 = (load4(s, 60) >> 3);
s11 += s23 * 666643;
s12 += s23 * 470296;
s13 += s23 * 654183;
s14 -= s23 * 997805;
s15 += s23 * 136657;
s16 -= s23 * 683901;
s10 += s22 * 666643;
s11 += s22 * 470296;
s12 += s22 * 654183;
s13 -= s22 * 997805;
s14 += s22 * 136657;
s15 -= s22 * 683901;
s9 += s21 * 666643;
s10 += s21 * 470296;
s11 += s21 * 654183;
s12 -= s21 * 997805;
s13 += s21 * 136657;
s14 -= s21 * 683901;
s8 += s20 * 666643;
s9 += s20 * 470296;
s10 += s20 * 654183;
s11 -= s20 * 997805;
s12 += s20 * 136657;
s13 -= s20 * 683901;
s7 += s19 * 666643;
s8 += s19 * 470296;
s9 += s19 * 654183;
s10 -= s19 * 997805;
s11 += s19 * 136657;
s12 -= s19 * 683901;
s6 += s18 * 666643;
s7 += s18 * 470296;
s8 += s18 * 654183;
s9 -= s18 * 997805;
s10 += s18 * 136657;
s11 -= s18 * 683901;
long c6 = (s6 + (1 << 20)) >> 21;
s7 += c6;
s6 -= c6 << 21;
long c8 = (s8 + (1 << 20)) >> 21;
s9 += c8;
s8 -= c8 << 21;
long c10 = (s10 + (1 << 20)) >> 21;
s11 += c10;
s10 -= c10 << 21;
long c12 = (s12 + (1 << 20)) >> 21;
s13 += c12;
s12 -= c12 << 21;
long c14 = (s14 + (1 << 20)) >> 21;
s15 += c14;
s14 -= c14 << 21;
long c16 = (s16 + (1 << 20)) >> 21;
s17 += c16;
s16 -= c16 << 21;
long c7 = (s7 + (1 << 20)) >> 21;
s8 += c7;
s7 -= c7 << 21;
long c9 = (s9 + (1 << 20)) >> 21;
s10 += c9;
s9 -= c9 << 21;
long c11 = (s11 + (1 << 20)) >> 21;
s12 += c11;
s11 -= c11 << 21;
long c13 = (s13 + (1 << 20)) >> 21;
s14 += c13;
s13 -= c13 << 21;
long c15 = (s15 + (1 << 20)) >> 21;
s16 += c15;
s15 -= c15 << 21;
s5 += s17 * 666643;
s6 += s17 * 470296;
s7 += s17 * 654183;
s8 -= s17 * 997805;
s9 += s17 * 136657;
s10 -= s17 * 683901;
s4 += s16 * 666643;
s5 += s16 * 470296;
s6 += s16 * 654183;
s7 -= s16 * 997805;
s8 += s16 * 136657;
s9 -= s16 * 683901;
s3 += s15 * 666643;
s4 += s15 * 470296;
s5 += s15 * 654183;
s6 -= s15 * 997805;
s7 += s15 * 136657;
s8 -= s15 * 683901;
s2 += s14 * 666643;
s3 += s14 * 470296;
s4 += s14 * 654183;
s5 -= s14 * 997805;
s6 += s14 * 136657;
s7 -= s14 * 683901;
s1 += s13 * 666643;
s2 += s13 * 470296;
s3 += s13 * 654183;
s4 -= s13 * 997805;
s5 += s13 * 136657;
s6 -= s13 * 683901;
s0 += s12 * 666643;
s1 += s12 * 470296;
s2 += s12 * 654183;
s3 -= s12 * 997805;
s4 += s12 * 136657;
s5 -= s12 * 683901;
long cc0 = (s0 + (1 << 20)) >> 21;
s1 += cc0;
s0 -= cc0 << 21;
long cc2 = (s2 + (1 << 20)) >> 21;
s3 += cc2;
s2 -= cc2 << 21;
long cc4 = (s4 + (1 << 20)) >> 21;
s5 += cc4;
s4 -= cc4 << 21;
long cc6 = (s6 + (1 << 20)) >> 21;
s7 += cc6;
s6 -= cc6 << 21;
long cc8 = (s8 + (1 << 20)) >> 21;
s9 += cc8;
s8 -= cc8 << 21;
long cc10 = (s10 + (1 << 20)) >> 21;
s11 += cc10;
s10 -= cc10 << 21;
long cc1 = (s1 + (1 << 20)) >> 21;
s2 += cc1;
s1 -= cc1 << 21;
long cc3 = (s3 + (1 << 20)) >> 21;
s4 += cc3;
s3 -= cc3 << 21;
long cc5 = (s5 + (1 << 20)) >> 21;
s6 += cc5;
s5 -= cc5 << 21;
long cc7 = (s7 + (1 << 20)) >> 21;
s8 += cc7;
s7 -= cc7 << 21;
long cc9 = (s9 + (1 << 20)) >> 21;
s10 += cc9;
s9 -= cc9 << 21;
long cc11 = (s11 + (1 << 20)) >> 21;
long s12_2 = cc11;
s11 -= cc11 << 21;
s0 += s12_2 * 666643;
s1 += s12_2 * 470296;
s2 += s12_2 * 654183;
s3 -= s12_2 * 997805;
s4 += s12_2 * 136657;
s5 -= s12_2 * 683901;
long ccc0 = s0 >> 21;
s1 += ccc0;
s0 -= ccc0 << 21;
long ccc1 = s1 >> 21;
s2 += ccc1;
s1 -= ccc1 << 21;
long ccc2 = s2 >> 21;
s3 += ccc2;
s2 -= ccc2 << 21;
long ccc3 = s3 >> 21;
s4 += ccc3;
s3 -= ccc3 << 21;
long ccc4 = s4 >> 21;
s5 += ccc4;
s4 -= ccc4 << 21;
long ccc5 = s5 >> 21;
s6 += ccc5;
s5 -= ccc5 << 21;
long ccc6 = s6 >> 21;
s7 += ccc6;
s6 -= ccc6 << 21;
long ccc7 = s7 >> 21;
s8 += ccc7;
s7 -= ccc7 << 21;
long ccc8 = s8 >> 21;
s9 += ccc8;
s8 -= ccc8 << 21;
long ccc9 = s9 >> 21;
s10 += ccc9;
s9 -= ccc9 << 21;
long ccc10 = s10 >> 21;
s11 += ccc10;
s10 -= ccc10 << 21;
long ccc11 = s11 >> 21;
long s12_3 = ccc11;
s11 -= ccc11 << 21;
s0 += s12_3 * 666643;
s1 += s12_3 * 470296;
s2 += s12_3 * 654183;
s3 -= s12_3 * 997805;
s4 += s12_3 * 136657;
s5 -= s12_3 * 683901;
long sccc0 = s0 >> 21;
s1 += sccc0;
s0 -= sccc0 << 21;
long sccc1 = s1 >> 21;
s2 += sccc1;
s1 -= sccc1 << 21;
long sccc2 = s2 >> 21;
s3 += sccc2;
s2 -= sccc2 << 21;
long sccc3 = s3 >> 21;
s4 += sccc3;
s3 -= sccc3 << 21;
long sccc4 = s4 >> 21;
s5 += sccc4;
s4 -= sccc4 << 21;
long sccc5 = s5 >> 21;
s6 += sccc5;
s5 -= sccc5 << 21;
Arrays.fill(s, (byte) 0);
long[] resLimbs = {s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11};
int bitIdx = 0;
for (int limbIdx = 0; limbIdx < 12; limbIdx++) {
for (int i = 0; i < 21; i++) {
if (bitIdx < 256) {
if (((resLimbs[limbIdx] >> i) & 1) == 1)
s[bitIdx / 8] |= (byte) (1 << (bitIdx % 8));
bitIdx++;
}
}
}
}
private static long load3(byte[] in, int off) {
return (in[off] & 0xffL) | ((in[off + 1] & 0xffL) << 8) | ((in[off + 2] & 0xffL) << 16);
}
private static long load4(byte[] in, int off) {
return (in[off] & 0xffL) | ((in[off + 1] & 0xffL) << 8) | ((in[off + 2] & 0xffL) << 16) | ((in[off + 3] & 0xffL) << 24);
}
private static void scalarMulAdd(byte[] S, byte[] k, byte[] s, byte[] r) {
long[] res = new long[64];
for (int i = 0; i < 32; i++) {
for (int j = 0; j < 32; j++) res[i + j] += (k[i] & 0xFFL) * (s[j] & 0xFFL);
}
for (int i = 0; i < 32; i++) res[i] += (r[i] & 0xFFL);
byte[] buffer = new byte[64];
long carry = 0;
for (int i = 0; i < 63; i++) {
long val = res[i] + carry;
buffer[i] = (byte) val;
carry = val >>> 8;
}
buffer[63] = (byte) (res[63] + carry);
reduceScalar(buffer);
System.arraycopy(buffer, 0, S, 0, 32);
}
private static byte[] sha512(byte[]... parts) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");
for (byte[] p : parts) md.update(p);
return md.digest();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,171 @@
/*
* 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 java.util.Arrays;
import swiss.qpq.gajumaru.core.tools.CryptoUtils;
// Pure Java implementation of Keccak-256 (Keccak[c=512] sponge).
// Based on sha3.erl.
public final class Keccak256 {
private static final int STATE_SIZE = 25;
private static final int CAPACITY = 512;
private static final int BITRATE = 1600 - CAPACITY; // 1088 bits = 136 bytes
private static final int BITRATE_BYTES = BITRATE / 8;
private static final long[] ROUND_CONSTANTS = {
0x0000000000000001L, 0x0000000000008082L, 0x800000000000808AL, 0x8000000080008000L,
0x000000000000808BL, 0x0000000080000001L, 0x8000000080008081L, 0x8000000000008009L,
0x000000000000008AL, 0x0000000000000088L, 0x0000000080008009L, 0x000000008000000AL,
0x000000008000808BL, 0x800000000000008BL, 0x8000000000008089L, 0x8000000000008003L,
0x8000000000008002L, 0x8000000000000080L, 0x000000000000800AL, 0x800000008000000AL,
0x8000000080008081L, 0x8000000000008080L, 0x0000000080000001L, 0x8000000080008008L
};
private static final int[] ROTATION_OFFSETS = {
0, 1, 62, 28, 27,
36, 44, 6, 55, 20,
3, 10, 43, 25, 39,
41, 45, 15, 21, 8,
18, 2, 61, 56, 14
};
private Keccak256() {}
public static byte[] hash(byte[] data) {
long[] state = new long[STATE_SIZE];
// Padding: Keccak[c=512] uses 0x01 ... 0x80 (or just 0x01 for non-NIST Keccak)
// Erlang code uses: keccak(Capacity, Message, <<>>, OutputBitLength)
// Pad logic in Erlang: <<Msg/bitstring, Delimiter/bitstring, 1:1, 0:PadZeros, 1:1>>
// With Delimiter = <<>>, it becomes <<1:1, 0:PadZeros, 1:1>> (standard Keccak padding)
byte[] padded = pad(data, BITRATE_BYTES);
for (int i = 0; i < padded.length; i += BITRATE_BYTES) {
absorb(state, padded, i);
}
byte[] result = squeeze(state, 32);
// Memory hygiene
Arrays.fill(state, 0L);
CryptoUtils.wipe(padded);
return result;
}
private static byte[] pad(byte[] data, int rateBytes) {
int mLen = data.length;
int padLen = rateBytes - (mLen % rateBytes);
byte[] padded = new byte[mLen + padLen];
System.arraycopy(data, 0, padded, 0, mLen);
if (padLen == 1) {
padded[mLen] = (byte) 0x81;
} else {
padded[mLen] = (byte) 0x01;
padded[padded.length - 1] |= (byte) 0x80;
}
return padded;
}
private static void absorb(long[] state, byte[] data, int offset) {
for (int i = 0; i < BITRATE_BYTES / 8; i++) {
state[i] ^= readLongLittleEndian(data, offset + i * 8);
}
keccakF(state);
}
private static byte[] squeeze(long[] state, int len) {
byte[] result = new byte[len];
int count = 0;
while (count < len) {
for (int i = 0; i < BITRATE_BYTES / 8 && count < len; i++) {
writeLongLittleEndian(state[i], result, count);
count += 8;
}
if (count < len) {
keccakF(state);
}
}
return result;
}
private static void keccakF(long[] a) {
for (int round = 0; round < 24; round++) {
// Theta
long[] c = new long[5];
for (int x = 0; x < 5; x++) {
c[x] = a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20];
}
for (int x = 0; x < 5; x++) {
long d = c[(x + 4) % 5] ^ Long.rotateLeft(c[(x + 1) % 5], 1);
for (int y = 0; y < 5; y++) {
a[x + y * 5] ^= d;
}
}
// Rho and Pi
long[] nextA = new long[STATE_SIZE];
for (int x = 0; x < 5; x++) {
for (int y = 0; y < 5; y++) {
int index = x + y * 5;
nextA[y + ((2 * x + 3 * y) % 5) * 5] = Long.rotateLeft(a[index], ROTATION_OFFSETS[index]);
}
}
// Chi
for (int y = 0; y < 5; y++) {
long[] row = new long[5];
for (int x = 0; x < 5; x++) {
row[x] = nextA[x + y * 5];
}
for (int x = 0; x < 5; x++) {
nextA[x + y * 5] = row[x] ^ ((~row[(x + 1) % 5]) & row[(x + 2) % 5]);
}
}
// Iota
nextA[0] ^= ROUND_CONSTANTS[round];
System.arraycopy(nextA, 0, a, 0, STATE_SIZE);
}
}
private static long readLongLittleEndian(byte[] b, int offset) {
long res = 0;
for (int i = 0; i < 8; i++) {
res |= ((long) (b[offset + i] & 0xFF)) << (i * 8);
}
return res;
}
private static void writeLongLittleEndian(long v, byte[] b, int offset) {
for (int i = 0; i < 8; i++) {
b[offset + i] = (byte) ((v >>> (i * 8)) & 0xFF);
}
}
}
@@ -0,0 +1,90 @@
/*
* 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 java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
/**
* Vault provides secure AES/GCM encryption and decryption with memory hygiene.
*/
public final class Vault {
private static final String AES_GCM = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH = 128; // Bits
private static final int IV_LENGTH = 12; // Bytes
private Vault() {}
/**
* Holds the results of an encryption operation.
*/
public record Ciphertext(byte[] iv, byte[] data) {}
/**
* Encrypts plaintext using AES/GCM.
* Manually generates the IV to ensure deterministic tag length and cross-provider consistency.
*/
public static Ciphertext encrypt(SecretKey key, byte[] plaintext) throws Exception {
try {
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
byte[] ciphertext = encrypt(key, iv, plaintext);
return new Ciphertext(iv, ciphertext);
} catch (Exception e) {
throw new Exception("Vault.encrypt failed: " + e.getMessage(), e);
}
}
/**
* Encrypts plaintext using AES/GCM with a specific IV.
*/
public static byte[] encrypt(SecretKey key, byte[] iv, byte[] plaintext) throws Exception {
try {
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);
} catch (Exception e) {
throw new Exception("Vault.encrypt(iv) failed: " + e.getClass().getName() + ": " + e.getMessage(), e);
}
}
/**
* Decrypts ciphertext using AES/GCM.
*/
public static byte[] decrypt(SecretKey key, byte[] iv, byte[] ciphertext) throws Exception {
try {
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);
} catch (Exception e) {
String msg = e.getMessage();
if (msg == null) msg = e.toString();
throw new Exception("Vault.decrypt failed [" + e.getClass().getSimpleName() + "]: " + msg, e);
}
}
}
@@ -0,0 +1,107 @@
/*
* 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.Arrays;
import java.util.HashMap;
import java.util.Map;
// Port of gmser_id.erl.
// Represents a 33-byte identifier (1-byte tag + 32-byte value).
public final class Id {
public enum Tag {
ACCOUNT(1),
NAME(2),
COMMITMENT(3),
CONTRACT(5),
CHANNEL(6),
ASSOCIATE_CHAIN(7),
NATIVE_TOKEN(8),
ENTRY(9);
public final int value;
Tag(int value) {
this.value = value;
}
private static final Map<Integer, Tag> VALUE_MAP = new HashMap<>();
static {
for (Tag t : Tag.values()) {
VALUE_MAP.put(t.value, t);
}
}
public static Tag fromValue(int v) {
Tag t = VALUE_MAP.get(v);
if (t == null) throw new IllegalArgumentException("Unknown ID tag value: " + v);
return t;
}
}
private final Tag tag;
private final byte[] value;
public Id(Tag tag, byte[] value) {
if (value.length != 32) {
throw new IllegalArgumentException("ID value must be exactly 32 bytes");
}
this.tag = tag;
this.value = Arrays.copyOf(value, 32);
}
public Tag getTag() { return tag; }
public byte[] getValue() { return Arrays.copyOf(value, 32); }
public byte[] serialize() {
byte[] result = new byte[33];
result[0] = (byte) tag.value;
System.arraycopy(value, 0, result, 1, 32);
return result;
}
public static Id deserialize(byte[] data) {
if (data.length != 33) {
throw new IllegalArgumentException("Serialized ID must be exactly 33 bytes");
}
Tag tag = Tag.fromValue(data[0] & 0xFF);
byte[] value = Arrays.copyOfRange(data, 1, 33);
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 id)) return false;
return tag == id.tag && Arrays.equals(value, id.value);
}
@Override
public int hashCode() {
int result = tag.hashCode();
result = 31 * result + Arrays.hashCode(value);
return result;
}
}
@@ -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
);
}
}
@@ -0,0 +1,164 @@
/*
* 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.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 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 {
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, Encoding encoding) {
this.prefix = prefix;
this.size = size;
this.encoding = encoding;
}
}
private static final Map<String, Type> PREFIX_MAP = new HashMap<>();
static {
for (Type t : Type.values()) {
PREFIX_MAP.put(t.prefix, t);
}
}
private ApiEncoder() {}
public static String encode(Type type, byte[] payload) {
if (type.size != -1 && payload.length != type.size) {
throw new IllegalArgumentException("Invalid payload size for " + type + ": " + payload.length);
}
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) {
int splitIdx = input.indexOf('_');
if (splitIdx == -1) {
throw new IllegalArgumentException("Invalid encoded string format (missing underscore)");
}
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: " + prefix);
}
byte[] payload;
if (type.encoding == Encoding.BASE58) {
payload = Base58.checkDecode(encoded);
} else {
payload = base64CheckDecode(encoded);
}
if (type.size != -1 && payload.length != type.size) {
throw new IllegalArgumentException("Invalid decoded payload size for " + type + ": " + payload.length);
}
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) {}
}
@@ -21,8 +21,11 @@
package swiss.qpq.gajumaru.core.encoding;
import java.util.Arrays;
import swiss.qpq.gajumaru.core.tools.CryptoUtils;
// Stateless Base58 and Base58Check implementation.
// Stateless Base58 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,61 @@ 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 = 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 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 = CryptoUtils.doubleSha256(data);
for (int i = 0; i < 4; i++) {
if (actual[i] != expected[i]) {
throw new IllegalArgumentException("Base58Check checksum mismatch");
}
}
return data;
}
// Internal Utilities
}
@@ -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);
}
}
@@ -0,0 +1,107 @@
/*
* 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.util.Arrays;
// Gf256 implements finite field arithmetic over GF(256).
// Ported from gf256.erl.
public final class Gf256 {
private final int[] exp;
private final int[] log;
public Gf256(int primeModulus) {
this.exp = new int[512];
this.log = new int[256];
int x = 1;
for (int i = 0; i < 255; i++) {
exp[i] = x;
exp[i + 255] = x;
log[x] = i;
x <<= 1;
if (x > 255) {
x ^= primeModulus;
}
}
}
public int add(int a, int b) {
return a ^ b;
}
public int multiply(int a, int b) {
if (a == 0 || b == 0) return 0;
return exp[log[a] + log[b]];
}
public int inverse(int x) {
if (x == 0) throw new ArithmeticException("Division by zero");
return exp[255 - log[x]];
}
public int exponent(int i) {
return exp[i % 255];
}
public int[] monomialProduct(int[] poly, int coeff, int degree) {
if (coeff == 0) return new int[]{0};
int[] result = new int[poly.length + degree];
for (int i = 0; i < poly.length; i++) {
result[i] = multiply(poly[i], coeff);
}
return result;
}
public int[] polynomialProduct(int[] p1, int[] p2) {
if (isZero(p1) || isZero(p2)) return new int[]{0};
int[] result = new int[p1.length + p2.length - 1];
for (int i = 0; i < p1.length; i++) {
if (p1[i] == 0) continue;
for (int j = 0; j < p2.length; j++) {
result[i + j] ^= multiply(p1[i], p2[j]);
}
}
return result;
}
public int[] divide(int[] a, int[] b) {
if (isZero(b)) throw new ArithmeticException("Division by zero");
int inv = inverse(b[0]);
int[] r = Arrays.copyOf(a, a.length);
int steps = a.length - b.length + 1;
for (int i = 0; i < steps; i++) {
if (r[i] == 0) continue;
int scale = multiply(r[i], inv);
for (int j = 0; j < b.length; j++) {
r[i + j] ^= multiply(b[j], scale);
}
}
// Remainder is the last b.length - 1 elements
return Arrays.copyOfRange(r, a.length - b.length + 1, a.length);
}
private boolean isZero(int[] poly) {
return poly.length == 0 || (poly.length == 1 && poly[0] == 0);
}
}
@@ -0,0 +1,594 @@
/*
* 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.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
// Mnemonic provides Gajumaru-style mnemonic phrase encoding and decoding.
// Port of hz_key_master.erl.
//
// Uses raw bit manipulation on byte[] to avoid BigInteger artifacts on the heap.
// Returns byte[][] for phrases to allow for explicit memory scrubbing.
public final class Mnemonic {
private static final int DICT_SIZE = 4096;
private static final int WIDTH = 12; // bits per word
private static final int MAX_DATA_CHUNKS = 22; // For 256-bit seed
public static final String[] WORDS = {
"aardvark", "abacus", "abalone", "abandon", "abbey", "abdomen", "abduct", "abhor", "abide",
"ability", "able", "abnormal", "aboard", "abolish", "abort", "above", "abrasive", "abridged",
"abroad", "abrupt", "abscond", "absence", "absinthe", "absorb", "abstract", "absurd", "abundant",
"abuser", "abyss", "acacia", "academic", "accent", "accident", "acclaim", "account", "accredit",
"accuse", "acetone", "achieve", "acid", "acne", "acolyte", "acoustic", "acquire", "acreage",
"acrobat", "acrylic", "activity", "actor", "actress", "actual", "acuity", "acumen", "acute",
"adage", "adamant", "adapt", "addendum", "addict", "address", "adequate", "adhesive", "adjacent",
"adjoin", "adjust", "admiral", "admonish", "adobe", "adoption", "adorn", "adrenal", "adult",
"advance", "advert", "advisor", "advocate", "aerobic", "affair", "affirm", "afflict", "afford",
"affront", "afraid", "africa", "again", "aged", "agency", "agile", "agitate", "agnostic", "agony",
"agrarian", "agree", "ahead", "ailment", "aimless", "airbrush", "airdrop", "airfare", "airline",
"airmail", "airport", "airship", "airtight", "aisle", "ajar", "alarm", "albacore", "albino",
"album", "alchemy", "alcove", "alert", "alfalfa", "algae", "algebra", "alias", "alibi", "alien",
"alimony", "alive", "alkaline", "alley", "allied", "allocate", "allure", "almanac", "almond",
"alone", "aloof", "alpaca", "alpha", "alpine", "alto", "altruism", "aluminum", "amateur", "amaze",
"amber", "ambition", "ambrosia", "ambush", "amend", "amethyst", "amicable", "amiss", "ammonia",
"amnesia", "amoeba", "amorous", "amount", "amperage", "amplify", "amputate", "amulet", "amuse",
"anaconda", "anagram", "analysis", "anarchy", "anatomy", "ancestor", "anchor", "ancient",
"android", "anecdote", "anemic", "aneurism", "angel", "angler", "angry", "anguish", "animal",
"ankle", "annex", "announce", "annual", "anoint", "anomaly", "anorexia", "another", "answer",
"antacid", "antenna", "anthem", "antique", "antonym", "anvil", "anxiety", "aorta", "apart",
"apathy", "aperture", "apex", "aphid", "apogee", "apology", "apostle", "apparel", "appear",
"apple", "appoint", "approve", "apricot", "apron", "aptitude", "aquatic", "arachnid", "arcade",
"archery", "arctic", "ardent", "arduous", "arena", "argue", "argyle", "arise", "armada",
"armchair", "armor", "armpit", "army", "aroma", "arouse", "arrange", "arrest", "arrive", "arrow",
"arsenal", "arson", "artefact", "artist", "artwork", "asbestos", "ascend", "ascot", "ashamed",
"ashtray", "ashy", "asia", "askew", "aspect", "asphalt", "aspirin", "assassin", "asset", "assist",
"assorted", "assume", "asteroid", "asthma", "astound", "astride", "astute", "asylum", "atheism",
"athlete", "atlas", "atoll", "atom", "atrium", "atrocity", "attack", "attend", "attic", "attorney",
"attract", "auburn", "auction", "audacity", "audit", "augment", "august", "aura", "aurora",
"austere", "author", "autopsy", "autumn", "avail", "avarice", "avatar", "avenger", "average",
"aviation", "avocado", "avoid", "await", "awake", "award", "awesome", "awkward", "awning", "awry",
"axe", "axiom", "axis", "azalea", "azimuth", "azure", "baboon", "babysit", "bachelor", "backhand",
"bacon", "bacteria", "badge", "baffle", "bagel", "baggage", "bagpipe", "bailiff", "bakery",
"balance", "balcony", "balder", "ballroom", "balmy", "baloney", "bamboo", "banana", "bandage",
"bane", "bangle", "banister", "banjo", "banknote", "banner", "banquet", "banshee", "baptize",
"barbecue", "barefoot", "bargain", "baritone", "bark", "barley", "barman", "barnyard", "baroness",
"barrel", "basalt", "baseball", "bashful", "basic", "basket", "bassinet", "baste", "bathe",
"battle", "bay", "bayonet", "bazooka", "beaker", "beam", "beanbag", "beard", "beastly", "beaten",
"beauty", "because", "beckon", "become", "bedbug", "bedroom", "bedsore", "beefy", "beehive",
"beeline", "beeswax", "beetle", "befall", "before", "befuddle", "beggar", "begin", "begrudge",
"beguile", "behavior", "behead", "behind", "behold", "beige", "believe", "bellhop", "belong",
"bemuse", "bench", "benefit", "benign", "bequeath", "berate", "beret", "berserk", "beseech",
"beside", "bespoke", "best", "beta", "betray", "between", "beverage", "beware", "bewilder",
"beyond", "biased", "bible", "bicep", "bicker", "bicycle", "bidding", "bifocal", "biggest",
"bigmouth", "bigotry", "bigwig", "bike", "bikini", "billfold", "binary", "binder", "bingo",
"binomial", "biology", "bionic", "biopsy", "bipedal", "birch", "birdcage", "birthday", "biscuit",
"bisect", "bisque", "bistro", "bitter", "bizarre", "blackout", "blade", "blamed", "blanket",
"blast", "blatant", "blazer", "bleak", "bleed", "blemish", "bless", "blimp", "blind", "blister",
"blitz", "blizzard", "bloated", "blob", "blockade", "blogger", "blonde", "blooper", "blossom",
"blouse", "blowgun", "blubber", "bludgeon", "bluejay", "bluffer", "blunder", "blur", "blustery",
"boastful", "boater", "bobcat", "bobsled", "bobtail", "bodega", "bodice", "bodywork", "bogey",
"bohemian", "boil", "boldface", "bolt", "bombard", "bonanza", "bond", "boneless", "bonfire",
"bonnet", "bonsai", "bonus", "boogie", "book", "boost", "bootleg", "borax", "bordered", "boredom",
"borrow", "bosom", "boss", "bosun", "botanist", "botched", "bother", "bottom", "botulism",
"boulder", "boundary", "bouquet", "bourbon", "boutique", "bovine", "bowler", "boxer", "boycott",
"boyhood", "bracelet", "brag", "braille", "bramble", "branch", "brass", "bratty", "brave",
"brawler", "brazen", "breathe", "breeze", "brethren", "brevity", "brewery", "briar", "bribery",
"brick", "bright", "brim", "brine", "brisket", "brittle", "broach", "broccoli", "broiler",
"broken", "bronze", "broom", "browse", "bruise", "brunt", "brute", "bubble", "buckle", "buddy",
"budget", "buffet", "builder", "bulb", "bulge", "bulimia", "bulky", "bulletin", "bummed", "bumpy",
"bundle", "bungalow", "bunion", "bunker", "buoyant", "burden", "bureau", "burger", "burial",
"burlap", "burnout", "burp", "burrito", "burst", "bushel", "business", "bustle", "busy", "butane",
"butcher", "butler", "button", "buxom", "buyer", "buyout", "buzz", "bylaw", "bypass", "cabaret",
"cabbage", "cabin", "cactus", "cadaver", "cadet", "caffeine", "caftan", "cage", "cairn", "cajole",
"calamity", "calcium", "calendar", "calico", "callous", "calm", "calorie", "calypso", "camera",
"campus", "canal", "cancel", "candy", "canine", "cannibal", "canoe", "canteen", "canvas", "canyon",
"capacity", "capital", "capsule", "capture", "caramel", "carbon", "carcass", "card", "careful",
"cargo", "caribou", "carnival", "carousel", "carry", "carsick", "cartoon", "carve", "cascade",
"cashew", "casino", "cassette", "castle", "casual", "catalog", "catcher", "category", "catnap",
"catwalk", "cauldron", "causeway", "caution", "cavalry", "caveman", "cavity", "ceiling", "celery",
"celibate", "cellmate", "cement", "censored", "center", "ceramic", "ceremony", "certain", "cesar",
"cesium", "cesspool", "chaff", "chagrin", "chair", "chalice", "champion", "change", "chaotic",
"chapter", "charity", "chase", "chat", "cheap", "checkers", "cheddar", "cheese", "chemical",
"cherry", "chestnut", "chevron", "chew", "chicken", "chief", "chiffon", "child", "chimney",
"china", "chipmunk", "chirp", "chisel", "chive", "chlorine", "choice", "choke", "cholera", "chomp",
"choppy", "chorus", "chowder", "chronic", "chubby", "chuckle", "chug", "chummy", "chunk", "churn",
"chutney", "cicada", "cider", "cigar", "cilantro", "cinema", "cinnamon", "cipher", "circle",
"cistern", "citadel", "citizen", "citrus", "city", "civil", "claim", "clammy", "clang", "clarify",
"class", "clatter", "clavicle", "clay", "cleanup", "cleft", "clemency", "clench", "clerk",
"clever", "client", "cliff", "climate", "clinic", "clipped", "cloaked", "clock", "clog",
"cloister", "closet", "clothing", "cloudy", "clove", "clown", "clubfoot", "clueless", "clump",
"clunky", "cluster", "clutch", "coach", "coastal", "coated", "cobalt", "cobbler", "cobra",
"cobweb", "coccyx", "cocktail", "coconut", "code", "coerce", "coffee", "cognac", "coherent",
"cohort", "coiled", "coin", "colander", "colder", "coleslaw", "coliseum", "collect", "color",
"column", "comatose", "combine", "comedy", "comfort", "comic", "common", "company", "comrade",
"concert", "conduct", "confirm", "congress", "conical", "conjoin", "connect", "conquer", "consume",
"control", "convince", "cookbook", "cool", "copper", "copy", "corduroy", "corner", "coronary",
"corporal", "correct", "corset", "cortex", "cosmetic", "cosplay", "costume", "cotton", "cougar",
"counter", "coupon", "courier", "cousin", "cover", "cowardly", "cowboy", "cowlick", "coyote",
"crabby", "crackle", "cradle", "craft", "cram", "crane", "crater", "craving", "crawl", "crayon",
"crazy", "creamy", "credit", "creep", "cremate", "crescent", "crevice", "cricket", "criminal",
"cringe", "crisis", "critical", "croak", "crochet", "crooked", "crop", "croquet", "crossbow",
"crouch", "crowd", "crucial", "cruel", "cruiser", "crumble", "crunch", "crush", "crux", "cryptic",
"crystal", "cube", "cuckoo", "cucumber", "cuddle", "cudgel", "cuff", "cuisine", "culinary",
"culprit", "cultural", "culvert", "cumin", "cumulus", "cunning", "cupboard", "cupcake", "cupid",
"curator", "curb", "curfew", "curled", "currency", "cursive", "curtsy", "curvy", "cushion", "cuss",
"custody", "cutback", "cutest", "cuticle", "cutlery", "cutout", "cycle", "cylinder", "cynic",
"cypress", "cyst", "dabble", "daffodil", "dainty", "daiquiri", "daisy", "damage", "damsel",
"dance", "dandruff", "danger", "dapper", "darkness", "darling", "dart", "dash", "database",
"dateline", "daughter", "daunting", "dawdle", "daybreak", "daydream", "daylight", "dazed",
"dazzle", "deadline", "dealer", "dean", "deathbed", "debate", "debrief", "debtor", "debut",
"decade", "deceased", "decision", "deck", "declare", "decorate", "decrease", "dedicate", "deduct",
"deed", "deepest", "deface", "defense", "define", "deflate", "deformed", "defraud", "deft",
"defuse", "degree", "deity", "dejected", "delay", "delegate", "deliver", "delta", "delusion",
"delve", "demand", "demeanor", "demise", "democrat", "demure", "denial", "denounce", "density",
"dentist", "deny", "depart", "depend", "depict", "deploy", "deposit", "depress", "depth", "deputy",
"derail", "derby", "derelict", "derive", "describe", "deserter", "desire", "desktop", "desolate",
"despair", "destroy", "detach", "detect", "detour", "devalue", "develop", "device", "devote",
"dewdrop", "diabetic", "diagnose", "dialogue", "diamond", "diaper", "diary", "diatribe", "dicey",
"dictate", "diesel", "diet", "differ", "digest", "digital", "dignity", "digress", "dilemma",
"diligent", "dilute", "diminish", "dimmer", "dimpled", "dingy", "dinner", "dinosaur", "diorama",
"diploma", "direct", "dirty", "disabled", "disburse", "disco", "disdain", "disease", "disguise",
"dishevel", "dismal", "dispense", "disrupt", "dissuade", "distance", "dive", "divide", "divorce",
"divulge", "dizzy", "docility", "dockyard", "doctor", "document", "dodge", "dodo", "dogged",
"doghouse", "dogmatic", "doldrums", "doll", "dolphin", "domain", "domestic", "dominant", "donate",
"donkey", "doomsday", "door", "dorky", "dorm", "dorsal", "dosage", "dossier", "dotted", "doubt",
"doughnut", "downtown", "dowry", "dozen", "draftee", "dragon", "drainage", "dramatic", "drapery",
"drastic", "draw", "dream", "dredge", "dress", "dribble", "dried", "drift", "drink", "driveway",
"drizzle", "drool", "droplet", "drought", "drove", "drowsy", "drudgery", "drug", "druid",
"drummer", "drywall", "dubious", "duckling", "dugout", "duke", "dumbbell", "dumpster", "dungeon",
"duo", "duplex", "duration", "dust", "dutchess", "dutiful", "duty", "duvet", "dwarf", "dwell",
"dwindle", "dynamic", "dyslexia", "eager", "eagle", "eardrum", "earl", "earmark", "earner",
"earphone", "earring", "earshot", "earth", "earwig", "easel", "eastward", "easy", "eatery", "ebb",
"ebony", "echo", "eclectic", "eclipse", "ecology", "economy", "ecstasy", "edge", "edible",
"edifice", "editor", "educate", "eel", "eery", "effigy", "effort", "eggnog", "eggplant",
"eggshell", "ego", "elapse", "elastic", "elated", "elbow", "elder", "election", "elegance",
"element", "elephant", "elevator", "eligible", "elite", "elixir", "ellipsis", "elm", "elongate",
"elope", "eloquent", "elude", "elusive", "emaciate", "email", "emanate", "embark", "embezzle",
"emblem", "embody", "embrace", "emerald", "emigrant", "eminent", "emission", "emoji", "emotion",
"empathy", "emperor", "emphasis", "employer", "empower", "empty", "emu", "emulate", "enamel",
"enchant", "enclose", "encoder", "encrypt", "encumber", "endeavor", "endless", "endorse", "endure",
"enemy", "energize", "engage", "engine", "engraver", "engulf", "enhance", "enigma", "enjoy",
"enlist", "enmity", "enormity", "enraged", "enrich", "enroll", "ensemble", "ensnare", "entangle",
"enthrone", "entire", "entrance", "entwine", "envelope", "envision", "envy", "enzyme", "epic",
"epidemic", "epigram", "epilepsy", "episode", "epitaph", "epoch", "epoxy", "equation", "equinox",
"eraser", "erect", "erode", "errand", "error", "erupt", "escape", "escort", "escrow", "esoteric",
"espresso", "essay", "essence", "estate", "esteem", "estimate", "estrange", "estuary", "eternal",
"ethereal", "ethical", "ethnic", "eulogy", "euphoric", "eureka", "euro", "evade", "evaluate",
"evasion", "event", "evict", "evidence", "evil", "evoke", "evolve", "exact", "exalted", "example",
"excavate", "excerpt", "exchange", "excite", "exclude", "excrete", "excuse", "execute", "exempt",
"exercise", "exhaust", "exhibit", "exhume", "exile", "exist", "exodus", "exorcist", "exotic",
"expand", "expert", "expire", "explain", "expose", "express", "extend", "extinct", "extort",
"extra", "eyeball", "eyeglass", "eyelash", "fabled", "fabric", "fabulous", "facade", "facelift",
"facility", "fact", "faculty", "faded", "failure", "fainter", "fairy", "faith", "fake", "falcon",
"fallout", "false", "famished", "famous", "fanatic", "fanboy", "fancy", "fanfare", "fang",
"fantasy", "farewell", "farmer", "farther", "fashion", "fasten", "fatal", "fathom", "fatigue",
"fatty", "faucet", "fault", "favorite", "fax", "fealty", "fearless", "feast", "feature", "federal",
"fedora", "fee", "feeble", "feedback", "feeler", "feign", "feisty", "feline", "felon", "feminine",
"femur", "fence", "feral", "fern", "ferocity", "ferret", "fertile", "fervent", "festival", "fetch",
"fetid", "feud", "fever", "fiasco", "fiber", "fiction", "fiddler", "fidelity", "fidget",
"fiendish", "fiery", "fiesta", "figure", "filament", "filch", "filet", "filigree", "filling",
"filter", "finance", "fine", "finger", "finish", "firewood", "firm", "first", "fiscal", "fishery",
"fissure", "fitful", "fixate", "fizz", "fjord", "flabby", "flag", "flail", "flaky", "flame",
"flannel", "flapjack", "flash", "flatten", "flaunt", "flavor", "flawless", "fleece", "fleshy",
"flexible", "flick", "flight", "flimsy", "fling", "flip", "flirt", "float", "floor", "floppy",
"floral", "floss", "flotsam", "flourish", "flower", "fluent", "fluff", "fluid", "flummox", "flunk",
"fluoride", "flurry", "flusher", "flute", "flux", "flypaper", "flywheel", "foam", "focus",
"fodder", "fog", "fold", "foliage", "folklore", "follow", "fondue", "font", "foolish", "football",
"forager", "forbid", "force", "forecast", "forfeit", "forget", "forklift", "forlorn", "formal",
"forsake", "fortune", "forum", "forward", "fossil", "fought", "foul", "founder", "foxhound",
"foxtrot", "foxy", "foyer", "fraction", "fragment", "frailty", "frantic", "fraught", "freaky",
"freckled", "freeway", "freight", "frenzy", "frequent", "freshman", "fret", "fridge", "friend",
"frighten", "fringed", "frisky", "fritter", "frizzy", "frock", "frolic", "frontier", "frost",
"froth", "frown", "frozen", "fructose", "frugal", "fruit", "fugitive", "fulcrum", "fulfill",
"fullback", "fumigate", "fund", "funeral", "fungus", "funny", "furious", "furl", "furnace",
"furrow", "furthest", "fuselage", "fusion", "fussy", "futile", "futon", "future", "fuzzy",
"gadget", "galaxy", "gallery", "gambler", "game", "gangster", "gap", "garage", "garden", "gargle",
"garish", "garlic", "garment", "garnet", "garrison", "garter", "gaseous", "gaslight", "gasoline",
"gastric", "gatepost", "gather", "gaudy", "gauge", "gauntlet", "gavel", "gawk", "gazette",
"gearbox", "gecko", "geeky", "geezer", "gelatin", "gemstone", "general", "genius", "genre",
"gentle", "genuine", "geology", "geometry", "gerbil", "germ", "gesture", "getaway", "geyser",
"ghastly", "ghetto", "ghost", "ghoul", "giddy", "gifted", "gigantic", "giggle", "gilded",
"gimmick", "giraffe", "girdle", "girlish", "girth", "gist", "gizmo", "gizzard", "glacier", "glad",
"glamour", "glance", "glare", "glassy", "glaucoma", "glazed", "gleam", "gleeful", "glen", "glib",
"glide", "glimpse", "glint", "glitter", "globe", "gloom", "glory", "glossary", "gloved", "glow",
"glucose", "glue", "gluttony", "glycerin", "glyph", "gnarled", "gnash", "gnaw", "gnome", "goalie",
"goatee", "goblet", "goddess", "goldfish", "golfer", "gondola", "good", "gooey", "goose", "gopher",
"gorge", "gorilla", "gosling", "gospel", "gossip", "gothic", "gourmet", "govern", "gown",
"grabber", "gracious", "graduate", "graffiti", "grainy", "grammar", "grant", "grape", "grasp",
"grateful", "gravity", "gray", "greasy", "green", "gremlin", "grew", "gridlock", "grief", "grill",
"grimace", "grin", "gristle", "gritty", "grizzly", "grocery", "groggy", "grommet", "groove",
"gross", "grotto", "grout", "grovel", "grownup", "grub", "gruesome", "gruff", "grumpy", "grungy",
"gryphon", "guard", "guava", "guess", "guidance", "guilty", "guitar", "gullible", "gulp", "gumbo",
"gumdrop", "gumption", "gunsmith", "gurney", "guru", "gusty", "gut", "guttural", "gym", "gymnast",
"gypsy", "gyration", "gyro", "habit", "hacksaw", "haggler", "haiku", "haircut", "halberd", "half",
"halibut", "hallway", "halogen", "halter", "hamlet", "hammer", "hamster", "handrail", "hangover",
"happy", "harass", "harbor", "hardwood", "harmonic", "harness", "harpoon", "harsh", "harvest",
"hashtag", "hassle", "hatchet", "hateful", "hatred", "hauler", "haunch", "havoc", "haystack",
"haywire", "hazard", "hazelnut", "hazmat", "headset", "health", "hearsay", "heat", "heavy",
"heckler", "hectic", "hedgehog", "hedonism", "heedful", "hegemony", "height", "heinous",
"heirloom", "heliport", "hello", "helmet", "helpful", "hemlock", "hen", "henchman", "henna",
"herald", "herbal", "heresy", "heritage", "hernia", "heron", "herring", "hesitate", "hexagon",
"hiatus", "hibiscus", "hiccup", "hickory", "hidden", "hideaway", "highway", "hijacker", "hiker",
"hilarity", "hill", "hinge", "hint", "hip", "hippo", "history", "hither", "hoagie", "hoarder",
"hoax", "hobby", "hobo", "hockey", "hoedown", "hoggish", "hoist", "holiday", "holler", "hologram",
"holster", "holy", "homage", "home", "homicide", "homonym", "honeydew", "honk", "honor", "hoodie",
"hookworm", "hooligan", "hoop", "hooray", "hopeful", "horizon", "hormone", "hornet", "horror",
"horseman", "hospital", "hostess", "hotel", "hourly", "housing", "howdy", "howitzer", "hub",
"hubcap", "hubris", "huge", "hula", "human", "humble", "humdrum", "humidity", "hummus", "humpback",
"hungry", "hunter", "hurdle", "hurry", "hurt", "husband", "hush", "husky", "hustler", "hyacinth",
"hybrid", "hydrate", "hyena", "hygiene", "hyper", "hyphen", "hypnosis", "hysteria", "iceberg",
"icicle", "icon", "icy", "idea", "identify", "ideology", "idiot", "idler", "idolize", "idyllic",
"igloo", "ignite", "ignore", "iguana", "illicit", "illusion", "imagery", "imbibe", "imbue",
"imitate", "immense", "immolate", "immune", "impasse", "impede", "implode", "impostor", "impress",
"impunity", "inbox", "incense", "inch", "incision", "include", "income", "incubate", "index",
"indicate", "industry", "inept", "inertia", "infant", "inferno", "infinity", "inflict", "info",
"infrared", "ingest", "ingot", "inhale", "inherit", "inhibit", "initial", "inject", "injure",
"inkwell", "inlet", "inmate", "innocent", "innuendo", "input", "inquiry", "insane", "insert",
"insignia", "insomnia", "inspect", "instruct", "insult", "intact", "interest", "intimate",
"intruder", "invasion", "investor", "invite", "invoke", "iodine", "ionize", "iris", "irksome",
"ironwork", "irritate", "island", "isolate", "isotope", "issue", "itchy", "item", "iterate",
"ivory", "jabber", "jackal", "jade", "jagged", "jaguar", "jailer", "jamboree", "janitor", "jargon",
"jasmine", "jaundice", "javelin", "jaw", "jawbone", "jaywalk", "jazz", "jealousy", "jeans",
"jeopardy", "jerky", "jersey", "jester", "jet", "jettison", "jewelry", "jiffy", "jigsaw", "jinx",
"jittery", "jock", "jogger", "joint", "joke", "jolly", "jostle", "journey", "joust", "joy",
"joyride", "joystick", "jubilant", "judge", "judicial", "judo", "jug", "juggler", "juicy",
"jujitsu", "jukebox", "jump", "junction", "junior", "junk", "juror", "jury", "justify", "juvenile",
"kabob", "kale", "kangaroo", "karaoke", "karma", "kayak", "kazoo", "keen", "keepsake", "keg",
"kennel", "keratin", "kerchief", "kerosene", "kestrel", "ketchup", "keyboard", "keyhole",
"keynote", "keystone", "keyword", "khaki", "kick", "kidder", "kidney", "killjoy", "kilogram",
"kilt", "kimono", "kind", "kinetic", "kinfolk", "kingdom", "kinsman", "kiosk", "kiss", "kitchen",
"kite", "kitten", "kiwi", "klutzy", "knapsack", "knee", "knife", "knitted", "knockout", "know",
"knuckle", "koala", "kumquat", "lab", "label", "labor", "lacerate", "lackey", "lacquer",
"lacrosse", "lactose", "ladder", "ladybug", "laggard", "lagoon", "lair", "lament", "laminate",
"lamp", "lancer", "landlord", "language", "lanky", "lantern", "lanyard", "laptop", "larceny",
"large", "larvae", "larynx", "lasagna", "lasso", "latex", "latitude", "latrine", "lattice",
"laugh", "laundry", "laureate", "lava", "lavender", "lavish", "lawful", "lawn", "lawyer",
"laxative", "layaway", "layout", "lazy", "leader", "leaflet", "league", "leaky", "leap", "learn",
"leash", "leathery", "lecture", "ledger", "leeway", "lefty", "legacy", "legend", "legible",
"legume", "legwork", "leisure", "lemming", "lemonade", "lend", "length", "leniency", "lentil",
"leopard", "leprosy", "lesion", "lesser", "lethargy", "letter", "level", "levitate", "levy",
"lexicon", "liaison", "liberty", "library", "license", "lichen", "lieu", "lifespan", "lift",
"ligament", "lighter", "likable", "lilac", "limbo", "lime", "limit", "linchpin", "linen",
"linoleum", "linseed", "lion", "lipid", "lipstick", "liquid", "lisp", "listen", "literary",
"lithium", "litigate", "little", "livery", "lizard", "llama", "loaded", "loaf", "loaner", "loathe",
"lobbyist", "lobotomy", "lobster", "location", "lockjaw", "locust", "logic", "logo", "loiterer",
"lollipop", "lonesome", "long", "lookout", "loopy", "looter", "lopsided", "lordship", "loser",
"lotion", "lottery", "lotus", "loud", "lounge", "lousy", "lovely", "lowland", "loyalty", "lozenge",
"lucid", "luck", "luggage", "lukewarm", "lullaby", "lumber", "luminous", "lunar", "lunch", "lure",
"luxury", "lychee", "lymph", "lyric", "macaroni", "machine", "macro", "mad", "maestro", "magazine",
"magenta", "maggot", "magic", "magma", "magnet", "mahogany", "maiden", "mailman", "maimed",
"maintain", "majestic", "majority", "makeup", "malaria", "malice", "mallard", "malted", "malware",
"mammal", "manager", "mandolin", "maneuver", "mango", "manhole", "manicure", "mankind", "manly",
"manpower", "mansion", "mantra", "manual", "maple", "marathon", "marble", "march", "mare",
"margin", "marine", "market", "marmot", "maroon", "marriage", "marshal", "martini", "marvel",
"mascara", "mashup", "masked", "mason", "massive", "master", "matador", "matchbox", "material",
"math", "matrix", "mattress", "maturity", "maverick", "maximum", "mayhem", "mayor", "meadow",
"meander", "measure", "meatball", "mechanic", "medalist", "medical", "medley", "meeting",
"megabyte", "melanoma", "mellow", "melody", "melt", "member", "meme", "memory", "menace", "mental",
"menu", "meow", "merchant", "merge", "merit", "mermaid", "mesh", "message", "metallic", "meteor",
"method", "metric", "miasma", "microbe", "midair", "midday", "midnight", "midpoint", "midriff",
"midst", "midterm", "midwife", "midyear", "mighty", "migraine", "mild", "mileage", "military",
"milkman", "million", "mimic", "mimosa", "minced", "mindful", "mineral", "minister", "minnow",
"minstrel", "minty", "minute", "miracle", "mirror", "mischief", "misery", "misfit", "mishap",
"missile", "mistake", "mitosis", "mitt", "mixture", "mnemonic", "mobile", "moccasin", "mocha",
"mockery", "modern", "modify", "module", "mogul", "moisture", "molasses", "moldy", "molecule",
"mollusk", "molten", "moment", "momma", "monarch", "money", "mongoose", "monitor", "monocle",
"monster", "month", "monument", "moocher", "moon", "mop", "moped", "moral", "morgue", "morning",
"morocco", "morphine", "morsel", "mortgage", "mosaic", "mosquito", "mossy", "motherly", "motivate",
"motley", "motor", "motto", "mountain", "mourn", "mouse", "mouthful", "move", "mucky", "mucus",
"muffin", "mugger", "mulch", "mullet", "multiply", "mummify", "mundane", "murmur", "muscle",
"museum", "mushroom", "musical", "musket", "mustang", "mutant", "mute", "mutilate", "mutter",
"mutual", "myopia", "myriad", "mystery", "mythical", "nacho", "nail", "naive", "naked", "namesake",
"nanny", "napkin", "narcotic", "narrator", "narwhal", "nasal", "nasty", "national", "nature",
"naughty", "nausea", "nautical", "navigate", "nearest", "nebula", "necklace", "nectar", "needle",
"negative", "neglect", "neighbor", "nemesis", "neon", "neoprene", "nephew", "nepotism", "nerd",
"nerve", "nestle", "network", "neuron", "neutral", "newborn", "newlywed", "newscast", "newton",
"next", "nexus", "niche", "nickel", "nicotine", "niece", "nifty", "nightcap", "nihilist", "nimble",
"ninja", "nippy", "nirvana", "nitpick", "nitrogen", "nitwit", "noble", "nobody", "nocturne",
"noise", "nomad", "nominee", "nonsense", "noodle", "normalcy", "north", "nosedive", "nostril",
"nosy", "notary", "notch", "notepad", "notice", "noun", "nourish", "novel", "noxious", "nozzle",
"nuance", "nuclear", "nugget", "nuisance", "nullify", "numb", "numeral", "nunnery", "nurse",
"nurture", "nutmeg", "nutrient", "nutshell", "nutty", "nylon", "oak", "oasis", "oatmeal",
"obedient", "obelisk", "obese", "obey", "obituary", "object", "oblige", "oblong", "oboe",
"obscure", "observe", "obsidian", "obsolete", "obstacle", "obtain", "obtuse", "obvious",
"occasion", "occupant", "ocean", "ocelot", "octave", "octopus", "ocular", "oddity", "odometer",
"odyssey", "offer", "offhand", "office", "offload", "offshore", "often", "ogre", "oilskin",
"ointment", "okay", "oligarch", "omelet", "omit", "omnivore", "oncoming", "onion", "online",
"onset", "onward", "onyx", "ooze", "opaque", "opera", "opinion", "oppose", "oppress", "option",
"opulent", "oracle", "orange", "orbit", "orchard", "order", "ordinary", "oregano", "organize",
"original", "ornament", "orphan", "orthodox", "osmosis", "ostrich", "otter", "ottoman", "ounce",
"outage", "outburst", "outcome", "outdoor", "outfit", "outgrow", "outhouse", "outlaw", "output",
"outrun", "outside", "outtake", "outward", "oval", "oven", "overall", "owl", "owner", "oxford",
"oxygen", "oxymoron", "oyster", "ozone", "pacific", "package", "paddle", "padlock", "pageant",
"pagoda", "painful", "pajamas", "palpable", "palsy", "paltry", "pamphlet", "panacea", "pancake",
"panda", "panel", "panic", "panorama", "panther", "papaya", "paper", "paprika", "papyrus",
"parade", "parcel", "pardon", "parental", "pariah", "parka", "parlor", "parody", "parrot",
"parsley", "particle", "passport", "pasta", "patchy", "patent", "pathway", "patio", "patrol",
"pattern", "pave", "pavilion", "pawnshop", "payday", "payload", "peaceful", "peanut", "pearly",
"peasant", "pebble", "pecan", "pectoral", "peculiar", "pedantic", "peddler", "pedestal",
"pedicure", "peerless", "pelican", "pellet", "penalty", "pencil", "pendant", "penguin", "penny",
"pensive", "pentagon", "pepper", "percent", "perfect", "period", "perjury", "perk", "permit",
"perplex", "person", "perturb", "peruse", "perverse", "pesky", "petal", "petition", "petrify",
"petunia", "pewter", "phalanx", "phantom", "pharmacy", "phlegm", "phobia", "phoenix", "phonetic",
"photo", "phrase", "physical", "piano", "pickup", "picnic", "picture", "pierce", "piety", "pigeon",
"piglet", "pigment", "pile", "pilfer", "pilgrim", "pillow", "pinball", "pincer", "pinhole", "pink",
"pinnacle", "pinpoint", "pinto", "pioneer", "pious", "pirate", "piston", "pitch", "pitfall",
"pitiful", "pitted", "pivot", "pixel", "pixie", "pizza", "placard", "plan", "plaque", "plasma",
"platform", "playlist", "plaza", "plead", "pledge", "plenty", "plethora", "pliable", "plot",
"plumage", "plunge", "plural", "plushy", "plywood", "poacher", "pockmark", "podcast", "podiatry",
"poem", "poignant", "pointy", "poison", "poker", "polarize", "polished", "polka", "pollen", "polo",
"polygon", "pomade", "pompom", "poncho", "pontoon", "ponytail", "pooch", "popcorn", "popular",
"porous", "porridge", "portion", "positive", "possible", "post", "potato", "potency", "pothole",
"potluck", "poultry", "poverty", "powder", "powerful", "pox", "practice", "prairie", "praline",
"prance", "prawn", "prayer", "preach", "precise", "predator", "prefer", "preheat", "prelude",
"premium", "prepare", "prequel", "pressure", "pretty", "prevent", "price", "priest", "primary",
"print", "priority", "prisoner", "privacy", "problem", "process", "produce", "profile", "program",
"prohibit", "project", "prologue", "promise", "pronoun", "proof", "property", "prorate",
"prospect", "protest", "proud", "provide", "prowl", "proxy", "prudish", "prune", "psalm", "pseudo",
"psychic", "puberty", "public", "pucker", "pudding", "pudgy", "puffy", "pugilist", "pulley",
"pulpit", "pulse", "puma", "pummel", "pumpkin", "puncture", "pundit", "pungent", "punish", "puny",
"pupil", "puppy", "purchase", "puree", "purity", "purple", "pursuit", "purveyor", "pushup",
"putrid", "putt", "puzzle", "pyramid", "pyre", "python", "quadrant", "quagmire", "quail", "quake",
"quality", "quantity", "quarter", "quasar", "queasy", "queen", "quell", "quench", "query", "quest",
"quick", "quiet", "quilted", "quirky", "quitter", "quiver", "quiz", "quote", "rabbit", "rabies",
"raccoon", "race", "radar", "radio", "radon", "rafter", "raggedy", "ragtag", "raider", "railroad",
"rainbow", "raisin", "rally", "rampage", "ramrod", "rancher", "random", "ransack", "rapid",
"rarity", "rascal", "raspy", "rat", "ration", "raunchy", "ravage", "raven", "ravioli", "razor",
"reaction", "ready", "realize", "reaper", "rebel", "rebound", "rebuttal", "receiver", "recharge",
"recipe", "reckless", "recliner", "recorder", "recruit", "rectify", "recycle", "redeemer",
"redhead", "redneck", "reduce", "redwood", "reef", "referee", "refinery", "reflect", "reform",
"refrain", "refugee", "regalia", "regency", "reggae", "region", "regret", "regular", "rehab",
"rehearse", "reject", "rejoice", "relaxed", "relevant", "relic", "reload", "remark", "remedy",
"reminder", "remnant", "removal", "render", "renegade", "renovate", "rent", "repair", "repeater",
"replica", "report", "reprisal", "reptile", "repute", "requiem", "rescue", "resemble", "resident",
"resonate", "response", "restroom", "result", "retailer", "retina", "retrieve", "reunion",
"reveler", "revive", "revolt", "reward", "rhapsody", "rhetoric", "rhino", "rhodium", "rhombus",
"rhubarb", "rhythm", "rib", "ribbon", "rich", "rickshaw", "ricochet", "riddle", "ride", "ridicule",
"riff", "rifle", "rightful", "rigid", "ringtone", "rinse", "riot", "ripple", "risk", "ritual",
"ritzy", "rival", "river", "roadwork", "roar", "roast", "robbery", "robe", "robin", "robot",
"robust", "rocky", "rodeo", "roguish", "romantic", "romp", "roofing", "rookie", "roommate",
"rosemary", "roster", "rotate", "rotten", "rouge", "roulette", "round", "routine", "rowboat",
"royal", "rubber", "rubric", "rucksack", "rudder", "rueful", "ruffian", "rugby", "rugged", "ruin",
"ruler", "ruminate", "rummage", "rumor", "rumple", "runner", "runoff", "runway", "rupture",
"rural", "rusted", "ruthless", "saber", "sabotage", "sadistic", "sadness", "safari", "safe",
"saffron", "saga", "said", "sailor", "saint", "salary", "salesman", "saliva", "salon", "salsa",
"salt", "salute", "salvage", "sampler", "samurai", "sanctum", "sandwich", "sanguine", "sanitize",
"sapling", "sapphire", "sarcasm", "sardine", "sassy", "satchel", "satisfy", "saturate", "sauce",
"sauna", "savanna", "savior", "savory", "savvy", "sawdust", "sawhorse", "sawmill", "scab",
"scaffold", "scale", "scamper", "scandal", "scapula", "scared", "scatter", "scavenge", "scenery",
"scepter", "scheme", "schism", "schnapps", "scholar", "science", "scimitar", "scissor", "scoff",
"scold", "scooter", "scope", "scorpion", "scotch", "scout", "scowl", "scramble", "screen",
"script", "scroll", "scrub", "scuba", "scuffed", "sculpt", "scumbag", "scurry", "scuttle",
"scythe", "seabird", "seafood", "sealant", "seamless", "seaplane", "search", "season", "seaweed",
"secluded", "second", "secret", "section", "security", "sedan", "sediment", "seek", "seepage",
"seesaw", "seethe", "segment", "seismic", "seizure", "seldom", "select", "self", "sellout",
"seltzer", "semantic", "semester", "seminar", "senator", "senior", "sensory", "sentence", "sepia",
"sepsis", "sequence", "serenade", "serfdom", "sergeant", "serious", "serpent", "serrated", "serum",
"service", "sesame", "session", "setback", "settle", "setup", "severity", "sewage", "sewing",
"sextant", "shabby", "shackle", "shadow", "shaft", "shaggy", "shaky", "shallow", "shame", "sharp",
"shawl", "sheathe", "sheet", "shelter", "shepherd", "sheriff", "shield", "shifty", "shimmy",
"shinbone", "shipyard", "shiver", "shock", "shoelace", "shop", "short", "shotgun", "shoulder",
"shove", "showoff", "shrapnel", "shred", "shrine", "shroud", "shrug", "shudder", "shuffle",
"shutdown", "sibling", "sickle", "sidewalk", "siege", "sierra", "signal", "silent", "silicone",
"silk", "silly", "silver", "similar", "simple", "simulate", "since", "sinew", "single", "sinkhole",
"sinus", "siphon", "siren", "sirloin", "sister", "sitcom", "size", "sizzle", "skate", "skeleton",
"skeptic", "sketch", "skewer", "skier", "skillet", "skimmed", "skin", "skirt", "skittish", "skull",
"skunk", "skydive", "skyline", "skyward", "slacker", "slalom", "slammer", "slant", "slather",
"sleaze", "sled", "sleepy", "slender", "sleuth", "slice", "slim", "slinky", "slipper", "slobber",
"slogan", "sloped", "sloth", "slouch", "sluggish", "slurp", "slush", "smart", "smash", "smear",
"smell", "smile", "smith", "smoke", "smolder", "smooth", "smother", "smudge", "smug", "snack",
"snag", "snake", "snapshot", "snarl", "snazzy", "sneaker", "sneeze", "snicker", "snide", "sniff",
"snip", "snobbish", "snooze", "snore", "snot", "snowball", "snuggle", "soaked", "soap", "sob",
"soccer", "society", "socket", "soda", "sodium", "soft", "soggy", "solar", "soldier", "solemn",
"solid", "soloist", "solstice", "solution", "solve", "sombrero", "somebody", "sonata", "songbird",
"sonic", "sooner", "soothe", "soprano", "sorbet", "sorcerer", "sorority", "sortie", "soulmate",
"source", "south", "souvenir", "soybean", "space", "spandex", "spark", "spasm", "spatula", "spawn",
"speak", "special", "speech", "spend", "spew", "sphere", "sphinx", "spicy", "spider", "spiffy",
"spigot", "spill", "spine", "spirit", "spit", "splash", "spleen", "splint", "splotchy", "splurge",
"spoil", "sponsor", "spoon", "sporty", "spotter", "spouse", "spray", "spreader", "sprinkle",
"sprout", "spruce", "spud", "spunky", "spurn", "spy", "spyglass", "square", "squeeze", "squirrel",
"sriracha", "stable", "staccato", "stadium", "stage", "stairway", "stalker", "stamp", "standard",
"stapler", "starve", "station", "staunch", "stay", "steady", "steer", "stellar", "stencil",
"stereo", "steward", "stick", "stifle", "stigma", "stilt", "stimulus", "stingray", "stipend",
"stir", "stockade", "stoic", "stolen", "stomach", "stone", "stool", "stopper", "storm", "stow",
"strategy", "street", "strike", "strong", "struggle", "stub", "stucco", "student", "stuff",
"stumble", "stunt", "stupor", "sturgeon", "stutter", "stylus", "stymie", "suave", "subdue",
"subject", "sublime", "submit", "subplot", "subsidy", "subtitle", "suburbia", "subvert", "subway",
"success", "sudden", "sudsy", "suffer", "sugar", "suggest", "suitable", "sulfur", "sullen",
"sultan", "summer", "sumo", "sunburn", "sundial", "sunken", "sunlight", "sunroof", "sunset",
"superior", "support", "supreme", "surface", "surgery", "surmount", "surname", "surprise",
"surround", "survive", "sushi", "suspect", "sustain", "swaddle", "swagger", "swampy", "swan",
"swarm", "swath", "sweater", "sweeper", "swerve", "swift", "swimmer", "swindler", "swipe",
"switch", "swivel", "swollen", "swoop", "sworn", "sycamore", "syllabus", "symbolic", "symmetry",
"sympathy", "synapse", "sync", "syndrome", "synergy", "synopsis", "syntax", "syringe", "syrup",
"system", "tablet", "taboo", "tacit", "tackle", "taco", "tactile", "tadpole", "taffy", "tag",
"tailpipe", "takeout", "talent", "talisman", "tall", "tamper", "tandem", "tangy", "tankard",
"tanned", "tantrum", "tapestry", "tapioca", "tardy", "target", "tariff", "tarmac", "tarnish",
"tarp", "tarrier", "tartar", "task", "tassel", "tasteful", "tattoo", "taunt", "tavern", "taxation",
"taxi", "teacher", "teal", "teamwork", "teapot", "teardrop", "teaser", "techno", "tedium",
"teenager", "teeth", "telecast", "teller", "temple", "tenant", "tendency", "tennis", "tenor",
"tension", "tentacle", "tenure", "tepid", "tequila", "terminal", "terrain", "terse", "tertiary",
"testify", "tetanus", "tether", "texture", "thank", "thatch", "thaw", "theater", "theft", "theme",
"theory", "therapy", "thesis", "thick", "thigh", "thimble", "thinner", "thirst", "thistle",
"thorn", "thought", "thrall", "threat", "thrive", "throaty", "thrum", "thud", "thumb", "thunder",
"thwart", "thyroid", "tiara", "tibia", "ticket", "tidal", "tidbit", "tidy", "tiger", "tight",
"timber", "timeline", "timid", "tinfoil", "tinker", "tinsel", "tinted", "tipsy", "tiptoe",
"tirade", "tired", "titanium", "title", "toad", "toaster", "tobacco", "toboggan", "today",
"toddler", "toenail", "tofu", "together", "toggle", "toilet", "token", "tolerate", "tomato",
"tomb", "tomcat", "tomorrow", "tonality", "toned", "tongs", "tonight", "tonnage", "tonsil",
"toolbox", "tooth", "topaz", "topology", "topple", "topsoil", "torch", "torment", "tornado",
"torpedo", "torque", "torrid", "torso", "torture", "torus", "total", "tote", "toucan", "tourist",
"toward", "tower", "township", "toxic", "track", "trader", "traffic", "tragic", "train",
"trample", "transfer", "trapeze", "trash", "trauma", "traveler", "treaty", "trek", "tremble",
"trend", "trespass", "trial", "tribe", "tricycle", "trident", "trigger", "trilogy", "trinket",
"trip", "triumph", "trivia", "trod", "troll", "trooper", "tropic", "trouble", "truant", "trucker",
"trudge", "truffle", "trumpet", "trunk", "trust", "truth", "try", "tsunami", "tuba", "tubby",
"tubular", "tuft", "tugboat", "tuition", "tulip", "tumbler", "tummy", "tumult", "tundra", "tuner",
"tungsten", "tunic", "tunnel", "turbine", "turf", "turkey", "turmoil", "turnip", "turret",
"turtle", "tussle", "tutor", "tutu", "tuxedo", "tweak", "tweet", "twerp", "twice", "twiddle",
"twilight", "twin", "twirl", "twist", "tycoon", "type", "typhoon", "typical", "tyrant", "ubiquity",
"ukulele", "ulcer", "ulterior", "ultimate", "ultra", "umbrella", "umpire", "uncle", "uncouth",
"under", "undo", "undulate", "unicycle", "uniform", "unique", "unisex", "unite", "universe",
"unkempt", "until", "unwieldy", "upbeat", "upchuck", "upcoming", "updraft", "upfront", "upgrade",
"upheaval", "uphill", "upkeep", "uplift", "upload", "upper", "upright", "uproar", "upscale",
"upset", "upstream", "uptake", "uptown", "upward", "uranium", "urban", "urchin", "urge", "useful",
"useless", "username", "usher", "usual", "usurper", "utensil", "utility", "utmost", "utopia",
"uvula", "vacant", "vaccine", "vacuum", "vagabond", "vagrant", "vague", "valet", "valid",
"valuable", "valve", "vampire", "vandal", "vanguard", "vanish", "vanquish", "vapor", "variety",
"varsity", "vascular", "vassal", "vast", "vector", "veer", "vegan", "veggie", "vehement",
"vehicle", "velocity", "velvet", "vendor", "veneer", "vengeful", "venison", "venom", "venture",
"venue", "veranda", "verb", "verdict", "verify", "vermin", "version", "vertebra", "vessel",
"vestige", "veteran", "veto", "viable", "viaduct", "vibrant", "vicinity", "victim", "video",
"view", "vigilant", "vigorous", "village", "vinegar", "vintage", "vinyl", "violence", "virtual",
"virus", "visa", "visceral", "visitor", "visor", "visual", "vital", "vitriol", "vivid", "vocal",
"voice", "volatile", "volcano", "volition", "volley", "voltage", "volume", "voodoo", "voter",
"voucher", "vowel", "voyage", "vulture", "wacky", "wafer", "waft", "waggle", "wagon", "waitress",
"waiver", "walkway", "wallet", "walnut", "walrus", "waltz", "wanderer", "wardrobe", "warhorse",
"warlord", "warmth", "warped", "warrior", "warthog", "washroom", "wasp", "wasted", "watch",
"waterbed", "wavy", "waxed", "wayward", "weaken", "wealthy", "weapon", "weary", "weather",
"weaver", "webbed", "webcam", "website", "wedding", "weedy", "weekend", "weep", "weird", "welcome",
"welfare", "western", "wetland", "whack", "whaler", "wharf", "wheeze", "whelp", "whiff", "whim",
"whinny", "whiplash", "whirl", "whisper", "white", "wicked", "widow", "wife", "wig", "wildfire",
"willowy", "wimpy", "windpipe", "winery", "wingtip", "winter", "wiper", "wireless", "wiry",
"wisdom", "wish", "wistful", "withdraw", "witness", "witty", "wizardry", "wobble", "woe", "wolf",
"woman", "wombat", "wonder", "woodwork", "woolen", "woozy", "work", "world", "wormhole", "worry",
"worship", "worthy", "wounded", "wraith", "wrangle", "wrapper", "wreath", "wreckage", "wrench",
"wrestle", "wretched", "wriggle", "wrinkle", "wrist", "written", "wrong", "xenon", "yacht", "yam",
"yank", "yarn", "year", "yelp", "yeoman", "yes", "yeti", "yield", "yodel", "yoga", "yogurt",
"yolk", "young", "yuletide", "yuppie", "zany", "zealot", "zebra", "zenith", "zeppelin", "zero",
"zesty", "zinc", "zipper", "zodiac", "zombie", "zoom", "zucchini", "zygote"
};
private static final Map<String, Integer> WORD_MAP = new HashMap<>(DICT_SIZE);
static {
for (int i = 0; i < WORDS.length; i++) {
WORD_MAP.put(WORDS[i], i);
}
}
private Mnemonic() {}
public static byte[][] encode(byte[] seed) {
if (seed.length != 32) {
throw new IllegalArgumentException("Seed must be 32 bytes");
}
int[] allChunks = new int[MAX_DATA_CHUNKS];
for (int i = 0; i < MAX_DATA_CHUNKS; i++) {
allChunks[i] = read12Bits(seed, i);
}
// Find the first non-zero data chunk (leading zero suppression)
int firstNonZero = -1;
for (int i = 0; i < MAX_DATA_CHUNKS; i++) {
if (allChunks[i] != 0) {
firstNonZero = i;
break;
}
}
int numDataChunks = (firstNonZero == -1) ? 0 : (MAX_DATA_CHUNKS - firstNonZero);
int checksum = 0;
for (int c : allChunks) {
checksum ^= c;
}
byte[][] phrase = new byte[numDataChunks + 1][];
phrase[0] = WORDS[checksum].getBytes(StandardCharsets.UTF_8);
for (int i = 0; i < numDataChunks; i++) {
phrase[i + 1] = WORDS[allChunks[firstNonZero + i]].getBytes(StandardCharsets.UTF_8);
}
return phrase;
}
public static byte[] decode(byte[][] phrase) {
if (phrase.length < 1) {
throw new IllegalArgumentException("Phrase too short");
}
int[] chunks = new int[phrase.length];
for (int i = 0; i < phrase.length; i++) {
String word = new String(phrase[i], StandardCharsets.UTF_8);
Integer idx = WORD_MAP.get(word);
if (idx == null) {
throw new IllegalArgumentException("Unknown word in mnemonic at index " + i);
}
chunks[i] = idx;
}
int expectedChecksum = chunks[0];
int actualChecksum = 0;
for (int i = 1; i < chunks.length; i++) {
actualChecksum ^= chunks[i];
}
if (expectedChecksum != actualChecksum) {
throw new RuntimeException("Mnemonic checksum failed");
}
int numDataChunks = chunks.length - 1;
byte[] result = new byte[32];
for (int i = 0; i < numDataChunks; i++) {
int chunkIdx = MAX_DATA_CHUNKS - numDataChunks + i;
write12Bits(result, chunkIdx, chunks[i + 1]);
}
return result;
}
private static int read12Bits(byte[] seed, int chunkIdx) {
int bitStart = chunkIdx * WIDTH;
int val = 0;
for (int i = 0; i < WIDTH; i++) {
int pos = bitStart + i;
if (pos >= 8) { // Skip 8-bit leading zero padding
int seedBitPos = pos - 8;
int byteIdx = seedBitPos / 8;
int bitInByte = 7 - (seedBitPos % 8); // Big-endian bit order
if (((seed[byteIdx] & 0xFF) >> bitInByte & 1) == 1) {
val |= (1 << (11 - i));
}
}
}
return val;
}
private static void write12Bits(byte[] seed, int chunkIdx, int val) {
int bitStart = chunkIdx * WIDTH;
for (int i = 0; i < WIDTH; i++) {
int pos = bitStart + i;
if (pos >= 8) {
int seedBitPos = pos - 8;
int byteIdx = seedBitPos / 8;
int bitInByte = 7 - (seedBitPos % 8);
if (((val >> (11 - i)) & 1) == 1) {
seed[byteIdx] |= (byte) (1 << bitInByte);
} else {
seed[byteIdx] &= (byte) ~(1 << bitInByte);
}
}
}
}
}
@@ -0,0 +1,367 @@
/*
* 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.util.Arrays;
// QR encoder for Byte mode.
// Ported from qr.erl.
public final class QR {
private QR() {}
public enum ECC {
M(0);
final int bits;
ECC(int bits) { this.bits = bits; }
}
private static final int PRIME_MODULUS = 285;
private static final int FORMAT_INFO_POLY = 1335;
private static final int FORMAT_INFO_MASK = 21522;
private static final int VERSION_INFO_POLY = 7973;
private static final int[][] ALIGNMENT_COORDINATES = {
{}, // V0
{}, // V1
{6, 18}, {6, 22}, {6, 26}, {6, 30}, {6, 34}, {6, 22, 38}, {6, 24, 42}, {6, 26, 46},
{6, 28, 50}, {6, 30, 54}, {6, 32, 58}, {6, 34, 62}, {6, 26, 46, 66}, {6, 26, 48, 70},
{6, 26, 50, 74}, {6, 30, 54, 78}, {6, 30, 56, 82}, {6, 30, 58, 86}, {6, 34, 62, 90},
{6, 28, 50, 72, 94}, {6, 26, 50, 74, 98}, {6, 30, 54, 78, 102}, {6, 28, 54, 80, 106},
{6, 32, 58, 84, 110}, {6, 30, 58, 86, 114}, {6, 34, 62, 90, 118}, {6, 26, 50, 74, 98, 122},
{6, 30, 54, 78, 102, 126}, {6, 26, 52, 78, 104, 130}, {6, 30, 56, 82, 108, 134},
{6, 34, 60, 86, 112, 138}, {6, 30, 58, 86, 114, 142}, {6, 34, 62, 90, 118, 146},
{6, 30, 54, 78, 102, 126, 150}, {6, 24, 50, 76, 102, 128, 154}, {6, 28, 54, 80, 106, 132, 158},
{6, 32, 58, 84, 110, 136, 162}, {6, 26, 54, 82, 110, 138, 166}, {6, 30, 58, 86, 114, 142, 170}
};
private record VersionInfo(ECC ecc, int version, int byteCapacity, int[][] blocks, int remainder) {}
private static final VersionInfo[] VERSION_TABLE = {
new VersionInfo(ECC.M, 1, 14, new int[][]{{1, 26, 16}}, 0),
new VersionInfo(ECC.M, 2, 26, new int[][]{{1, 44, 28}}, 7),
new VersionInfo(ECC.M, 3, 42, new int[][]{{1, 70, 44}}, 7),
new VersionInfo(ECC.M, 4, 62, new int[][]{{2, 50, 32}}, 7),
new VersionInfo(ECC.M, 5, 84, new int[][]{{2, 67, 43}}, 7),
new VersionInfo(ECC.M, 6, 106, new int[][]{{4, 43, 27}}, 7),
new VersionInfo(ECC.M, 7, 122, new int[][]{{4, 49, 31}}, 0),
new VersionInfo(ECC.M, 8, 152, new int[][]{{2, 60, 38}, {2, 61, 39}}, 0),
new VersionInfo(ECC.M, 9, 180, new int[][]{{3, 58, 36}, {2, 59, 37}}, 0),
new VersionInfo(ECC.M, 10, 213, new int[][]{{4, 69, 43}, {1, 70, 44}}, 0),
new VersionInfo(ECC.M, 11, 251, new int[][]{{1, 80, 50}, {4, 81, 51}}, 0),
new VersionInfo(ECC.M, 12, 287, new int[][]{{6, 58, 36}, {2, 59, 37}}, 0),
new VersionInfo(ECC.M, 13, 331, new int[][]{{8, 59, 37}, {1, 60, 38}}, 0),
new VersionInfo(ECC.M, 14, 362, new int[][]{{4, 64, 40}, {5, 65, 41}}, 3),
new VersionInfo(ECC.M, 15, 412, new int[][]{{5, 65, 41}, {5, 66, 42}}, 3),
new VersionInfo(ECC.M, 40, 3391, new int[][]{{1, 3391, 2953}}, 0)
};
public static boolean[][] encode(String text) {
return encode(text.getBytes(java.nio.charset.StandardCharsets.UTF_8), ECC.M);
}
public static boolean[][] encode(byte[] data, ECC ecc) {
VersionInfo vi = chooseVersion(data.length, ecc);
if (vi == null) throw new IllegalArgumentException("Data too large for supported versions");
BitBuffer buffer = new BitBuffer();
buffer.add(4, 4); // Byte mode
int cciBits = vi.version <= 9 ? 8 : 16;
buffer.add(data.length, cciBits);
for (byte b : data) buffer.add(b & 0xFF, 8);
buffer.add(0, 4); // Terminator
int totalDataBytesCount = 0;
for (int[] b : vi.blocks) totalDataBytesCount += b[0] * b[2];
if (buffer.bitCount() % 8 != 0) buffer.add(0, 8 - (buffer.bitCount() % 8));
boolean toggle = true;
while (buffer.bitCount() / 8 < totalDataBytesCount) {
buffer.add(toggle ? 0xEC : 0x11, 8);
toggle = !toggle;
}
byte[] codewords = generateCodewords(vi, buffer.toByteArray());
int dim = 17 + vi.version * 4;
boolean[][] matrix = new boolean[dim][dim];
boolean[][] reserved = new boolean[dim][dim];
placeFixedPatterns(vi, matrix, reserved);
placeData(vi, matrix, reserved, codewords, vi.remainder);
applyMask(matrix, reserved, 0);
placeFormatInfo(vi, matrix, 0);
if (vi.version >= 7) placeVersionInfo(vi, matrix);
return matrix;
}
private static VersionInfo chooseVersion(int length, ECC ecc) {
for (VersionInfo vi : VERSION_TABLE) {
if (vi.ecc == ecc && vi.byteCapacity >= length) return vi;
}
return null;
}
private static byte[] generateCodewords(VersionInfo vi, byte[] data) {
Gf256 field = new Gf256(PRIME_MODULUS);
int totalBlocks = 0;
for (int[] b : vi.blocks) totalBlocks += b[0];
byte[][] dataBlocks = new byte[totalBlocks][];
byte[][] eccBlocks = new byte[totalBlocks][];
int blockIdx = 0;
int dataOffset = 0;
for (int[] b : vi.blocks) {
int count = b[0];
int dataBytes = b[2];
int totalBytes = b[1];
int eccBytes = totalBytes - dataBytes;
int[] gen = generator(field, eccBytes);
for (int i = 0; i < count; i++) {
byte[] block = Arrays.copyOfRange(data, dataOffset, dataOffset + dataBytes);
dataBlocks[blockIdx] = block;
eccBlocks[blockIdx] = encodeRS(field, block, gen, eccBytes);
dataOffset += dataBytes;
blockIdx++;
}
}
int totalCodewords = 0;
for (int[] b : vi.blocks) totalCodewords += b[0] * b[1];
byte[] result = new byte[totalCodewords];
int resOffset = 0;
int maxData = 0;
for (byte[] b : dataBlocks) maxData = Math.max(maxData, b.length);
for (int i = 0; i < maxData; i++) {
for (byte[] b : dataBlocks) {
if (i < b.length) result[resOffset++] = b[i];
}
}
int eccLen = eccBlocks[0].length;
for (int i = 0; i < eccLen; i++) {
for (byte[] b : eccBlocks) {
result[resOffset++] = b[i];
}
}
return result;
}
private static int[] generator(Gf256 f, int degree) {
int[] g = {1};
for (int i = 0; i < degree; i++) {
g = f.polynomialProduct(g, new int[]{1, f.exponent(i)});
}
return g;
}
private static byte[] encodeRS(Gf256 f, byte[] data, int[] gen, int eccBytes) {
int[] poly = new int[data.length + eccBytes];
for (int i = 0; i < data.length; i++) poly[i] = data[i] & 0xFF;
int[] rem = f.divide(poly, gen);
byte[] res = new byte[eccBytes];
int offset = eccBytes - rem.length;
for (int i = 0; i < rem.length; i++) res[offset + i] = (byte) rem[i];
return res;
}
private static void placeFixedPatterns(VersionInfo vi, boolean[][] matrix, boolean[][] reserved) {
int dim = matrix.length;
placeFinder(matrix, reserved, 0, 0);
placeFinder(matrix, reserved, dim - 7, 0);
placeFinder(matrix, reserved, 0, dim - 7);
// Separators around finders
for (int i = 0; i < 8; i++) {
reserved[7][i] = reserved[i][7] = true;
reserved[7][dim - 1 - i] = reserved[i][dim - 8] = true;
reserved[dim - 8][i] = reserved[dim - 1 - i][7] = true;
}
// Timing patterns
for (int i = 8; i < dim - 8; i++) {
matrix[6][i] = matrix[i][6] = (i % 2 == 0);
reserved[6][i] = reserved[i][6] = true;
}
// Alignment patterns
int[] coords = ALIGNMENT_COORDINATES[vi.version];
for (int y : coords) {
for (int x : coords) {
if (isFinderArea(x, y, dim)) continue;
placeAlignment(matrix, reserved, x, y);
}
}
// Dark module
matrix[4 * vi.version + 9][8] = true;
reserved[4 * vi.version + 9][8] = true;
// Reserved areas for format and version info
for (int i = 0; i < 9; i++) reserved[i][8] = reserved[8][i] = true;
for (int i = 0; i < 8; i++) reserved[dim - 1 - i][8] = reserved[8][dim - 1 - i] = true;
if (vi.version >= 7) {
for (int i = 0; i < 6; i++) {
for (int j = 0; j < 3; j++) {
reserved[dim - 11 + j][i] = reserved[i][dim - 11 + j] = true;
}
}
}
}
private static boolean isFinderArea(int x, int y, int dim) {
return (x < 9 && y < 9) || (x > dim - 10 && y < 9) || (x < 9 && y > dim - 10);
}
private static void placeFinder(boolean[][] m, boolean[][] r, int x, int y) {
for (int j = 0; j < 7; j++) {
for (int i = 0; i < 7; i++) {
int px = x + i, py = y + j;
r[py][px] = true;
boolean black = (i == 0 || i == 6 || j == 0 || j == 6 || (i >= 2 && i <= 4 && j >= 2 && j <= 4));
m[py][px] = black;
}
}
}
private static void placeAlignment(boolean[][] m, boolean[][] r, int x, int y) {
for (int j = -2; j <= 2; j++) {
for (int i = -2; i <= 2; i++) {
r[y + j][x + i] = true;
m[y + j][x + i] = (Math.abs(i) == 2 || Math.abs(j) == 2 || (i == 0 && j == 0));
}
}
}
private static void placeData(VersionInfo vi, boolean[][] m, boolean[][] r, byte[] data, int remainderBits) {
int dim = m.length;
int bitIdx = 0;
int totalBits = data.length * 8 + remainderBits;
int x = dim - 1, y = dim - 1, dir = -1;
while (x >= 0) {
if (x == 6) x--;
for (int i = 0; i < 2; i++) {
int cx = x - i;
if (!r[y][cx]) {
if (bitIdx < totalBits) {
int byteIdx = bitIdx / 8;
int shift = 7 - (bitIdx % 8);
boolean bit = byteIdx < data.length && ((data[byteIdx] >> shift) & 1) != 0;
m[y][cx] = bit;
bitIdx++;
}
}
}
y += dir;
if (y < 0 || y >= dim) {
y -= dir; x -= 2; dir = -dir;
}
}
}
private static void applyMask(boolean[][] m, boolean[][] r, int pattern) {
for (int y = 0; y < m.length; y++) {
for (int x = 0; x < m.length; x++) {
if (!r[y][x] && isMasked(x, y, pattern)) m[y][x] = !m[y][x];
}
}
}
private static boolean isMasked(int x, int y, int pattern) {
return switch (pattern) {
case 0 -> (x + y) % 2 == 0;
case 1 -> y % 2 == 0;
case 2 -> x % 3 == 0;
case 3 -> (x + y) % 3 == 0;
case 4 -> (y / 2 + x / 3) % 2 == 0;
case 5 -> ((x * y) % 2) + ((x * y) % 3) == 0;
case 6 -> (((x * y) % 2) + ((x * y) % 3)) % 2 == 0;
case 7 -> (((x + y) % 2) + ((x * y) % 3)) % 2 == 0;
default -> false;
};
}
private static final int[][] FORMAT_INFO_COORDS_TL = {
{8, 0}, {8, 1}, {8, 2}, {8, 3}, {8, 4}, {8, 5}, {8, 7}, {8, 8}, {7, 8}, {5, 8}, {4, 8}, {3, 8}, {2, 8}, {1, 8}, {0, 8}
};
private static void placeFormatInfo(VersionInfo vi, boolean[][] m, int mask) {
int info = (vi.ecc.bits << 3) | mask;
int bch = bch(info, FORMAT_INFO_POLY, 10);
int full = ((info << 10) | bch) ^ FORMAT_INFO_MASK;
int dim = m.length;
for (int i = 0; i < 15; i++) {
boolean bit = ((full >> i) & 1) != 0;
// Top-left strip
m[FORMAT_INFO_COORDS_TL[i][0]][FORMAT_INFO_COORDS_TL[i][1]] = bit;
// Bottom-left / Top-right strips
if (i < 8) m[8][dim - 1 - i] = bit;
else m[dim - 15 + i][8] = bit;
}
}
private static void placeVersionInfo(VersionInfo vi, boolean[][] m) {
int bch = bch(vi.version, VERSION_INFO_POLY, 12);
int full = (vi.version << 12) | bch;
int dim = m.length;
for (int i = 0; i < 18; i++) {
boolean bit = ((full >> i) & 1) != 0;
m[dim - 11 + i % 3][i / 3] = bit;
m[i / 3][dim - 11 + i % 3] = bit;
}
}
private static int bch(int data, int poly, int degree) {
int d = data << degree;
int msb = 31 - Integer.numberOfLeadingZeros(poly);
for (int i = 31 - Integer.numberOfLeadingZeros(d); i >= degree; i--) {
if (((d >> i) & 1) != 0) d ^= (poly << (i - msb));
}
return d;
}
private static class BitBuffer {
private byte[] data = new byte[32];
private int bitIdx = 0;
public void add(int value, int bits) {
ensureCapacity((bitIdx + bits + 7) / 8);
for (int i = bits - 1; i >= 0; i--) {
if (((value >> i) & 1) != 0) data[bitIdx / 8] |= (1 << (7 - (bitIdx % 8)));
bitIdx++;
}
}
public int bitCount() { return bitIdx; }
public byte[] toByteArray() { return Arrays.copyOf(data, (bitIdx + 7) / 8); }
private void ensureCapacity(int bytes) {
if (bytes > data.length) data = Arrays.copyOf(data, Math.max(data.length * 2, bytes));
}
}
}
@@ -0,0 +1,232 @@
/*
* 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.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public final class RLP {
// RLP only has two types: byte arrays and lists of lists of byte arrays
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];
}
public byte[] getBytes() { return bytes; }
}
public static final class RLP_List extends RLP_Data {
public final List<RLP_Data> items;
public RLP_List(List<RLP_Data> items) {
this.items = (items != null) ? items : new ArrayList<>();
}
public List<RLP_Data> getItems() { return items; }
}
public record DecodeResult(RLP_Data data, int consumed) {}
// API
private RLP() {}
public static byte[] encode(RLP_Data data) {
if (data instanceof RLP_Item item) {
return encodeItem(item.bytes);
} else if (data instanceof RLP_List list) {
return encodeList(list.items);
}
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;
if (prefix <= 0x7F) {
return new DecodeResult(new RLP_Item(new byte[] { (byte) prefix }), 1);
}
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);
}
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);
}
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);
}
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) {
if (bytes.length == 1 && (bytes[0] & 0xFF) <= 0x7F) {
return bytes;
}
return prefixData(bytes, 0x80, 0xB7);
}
private static byte[] encodeList(List<RLP_Data> items) {
if (items.isEmpty()) {
return new byte[] { (byte) 0xC0 };
}
byte[][] encodedChildren = new byte[items.size()][];
int totalPayloadLen = 0;
for (int i = 0; i < items.size(); i++) {
encodedChildren[i] = encode(items.get(i));
totalPayloadLen += encodedChildren[i].length;
}
byte[] prefix = prefixLength(totalPayloadLen, 0xC0, 0xF7);
byte[] result = new byte[prefix.length + totalPayloadLen];
System.arraycopy(prefix, 0, result, 0, prefix.length);
int ptr = prefix.length;
for (byte[] child : encodedChildren) {
System.arraycopy(child, 0, result, ptr, child.length);
ptr += child.length;
}
return result;
}
private static byte[] prefixData(byte[] payload, int shortOffset, int longOffset) {
byte[] prefix = prefixLength(payload.length, shortOffset, longOffset);
byte[] result = new byte[prefix.length + payload.length];
System.arraycopy(prefix, 0, result, 0, prefix.length);
System.arraycopy(payload, 0, result, prefix.length, payload.length);
return result;
}
private static byte[] prefixLength(int len, int shortOffset, int longOffset) {
if (len <= 55) {
return new byte[] { (byte) (shortOffset + len) };
}
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[] b = new byte[size];
for (int i = size - 1; i >= 0; i--) {
b[i] = (byte) (val & 0xFF);
val >>>= 8;
}
return b;
}
private static RLP_List parseListSequence(byte[] b, int cursor, int limit) {
List<RLP_Data> elements = new ArrayList<>();
while (cursor < limit) {
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[] b, int start, int end) {
int res = 0;
for (int i = start; i < end; i++) {
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);
}
}
}
@@ -0,0 +1,344 @@
/*
* 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.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
// ZJ: Tiny JSON parser, ported from zj.erl.
//
// NOTE:
// Does not *quite* live up to ZJ in every respect, but is sufficient for GRIDS.
// Uses standard Java exceptions for errors.
public final class ZJ {
public static class ParseException extends RuntimeException {
public final int position;
public ParseException(String message, int position) {
super(message + " at position " + position);
this.position = position;
}
}
private ZJ() {}
public static Object decode(String json) {
if (json == null || json.isEmpty()) return null;
return new Parser(json).parse();
}
public static String encode(Object value) {
StringBuilder sb = new StringBuilder();
encodeValue(value, sb);
return sb.toString();
}
private static void encodeValue(Object value, StringBuilder sb) {
if (value == null) {
sb.append("null");
} else if (value instanceof String) {
sb.append('"');
escape((String) value, sb);
sb.append('"');
} else if (value instanceof Boolean) {
sb.append(value);
} else if (value instanceof Number) {
sb.append(value);
} else if (value instanceof Map) {
encodeMap((Map<?, ?>) value, sb);
} else if (value instanceof List) {
encodeList((List<?>) value, sb);
} else if (value instanceof Object[]) {
encodeArray((Object[]) value, sb);
} else {
throw new IllegalArgumentException("Unsupported JSON value type: " + value.getClass().getName());
}
}
private static void encodeMap(Map<?, ?> map, StringBuilder sb) {
sb.append('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) sb.append(',');
first = false;
sb.append('"');
escape(entry.getKey().toString(), sb);
sb.append("\":");
encodeValue(entry.getValue(), sb);
}
sb.append('}');
}
private static void encodeList(List<?> list, StringBuilder sb) {
sb.append('[');
boolean first = true;
for (Object item : list) {
if (!first) sb.append(',');
first = false;
encodeValue(item, sb);
}
sb.append(']');
}
private static void encodeArray(Object[] array, StringBuilder sb) {
sb.append('[');
boolean first = true;
for (Object item : array) {
if (!first) sb.append(',');
first = false;
encodeValue(item, sb);
}
sb.append(']');
}
private static void escape(String s, StringBuilder sb) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\b' -> sb.append("\\b");
case '\f' -> sb.append("\\f");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 32 || c > 126) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
}
private static class Parser {
private final String json;
private int pos = 0;
Parser(String json) {
this.json = json;
}
Object parse() {
seek();
if (pos >= json.length()) return null;
Object result = value();
seek();
if (pos != json.length()) {
throw new ParseException("Unexpected trailing data", pos);
}
return result;
}
private Object value() {
char c = json.charAt(pos);
return switch (c) {
case '{' -> object();
case '[' -> array();
case '"' -> string();
case 't' -> bool(true);
case 'f' -> bool(false);
case 'n' -> nil();
default -> {
if (c == '-' || (c >= '0' && c <= '9')) {
yield number();
}
throw new ParseException("Unexpected character: " + c, pos);
}
};
}
private Map<String, Object> object() {
pos++; // skip '{'
Map<String, Object> map = new LinkedHashMap<>();
seek();
if (pos < json.length() && json.charAt(pos) == '}') {
pos++;
return map;
}
while (true) {
seek();
String key = string();
seek();
if (pos >= json.length() || json.charAt(pos) != ':') {
throw new ParseException("Expected ':'", pos);
}
pos++;
seek();
Object val = value();
map.put(key, val);
seek();
if (pos < json.length() && json.charAt(pos) == '}') {
pos++;
break;
}
if (pos >= json.length() || json.charAt(pos) != ',') {
throw new ParseException("Expected ',' or '}'", pos);
}
pos++;
}
return map;
}
private List<Object> array() {
pos++; // skip '['
List<Object> list = new ArrayList<>();
seek();
if (pos < json.length() && json.charAt(pos) == ']') {
pos++;
return list;
}
while (true) {
seek();
list.add(value());
seek();
if (pos < json.length() && json.charAt(pos) == ']') {
pos++;
break;
}
if (pos >= json.length() || json.charAt(pos) != ',') {
throw new ParseException("Expected ',' or ']'", pos);
}
pos++;
}
return list;
}
private String string() {
if (json.charAt(pos) != '"') throw new ParseException("Expected '\"'", pos);
pos++;
StringBuilder sb = new StringBuilder();
while (pos < json.length()) {
char c = json.charAt(pos++);
if (c == '"') return sb.toString();
if (c < 0x20) throw new ParseException("Unescaped control character", pos - 1);
if (c == '\\') {
if (pos >= json.length()) throw new ParseException("Unterminated escape", pos);
char esc = json.charAt(pos++);
switch (esc) {
case '"' -> sb.append('"');
case '\\' -> sb.append('\\');
case '/' -> sb.append('/');
case 'b' -> sb.append('\b');
case 'f' -> sb.append('\f');
case 'n' -> sb.append('\n');
case 'r' -> sb.append('\r');
case 't' -> sb.append('\t');
case 'u' -> {
if (pos + 4 > json.length()) throw new ParseException("Invalid unicode escape", pos);
String hex = json.substring(pos, pos + 4);
try {
sb.append((char) Integer.parseInt(hex, 16));
} catch (NumberFormatException e) {
throw new ParseException("Invalid hex in unicode escape", pos);
}
pos += 4;
}
default -> throw new ParseException("Unknown escape: " + esc, pos - 1);
}
} else {
sb.append(c);
}
}
throw new ParseException("Unterminated string", pos);
}
private Boolean bool(boolean expected) {
String s = expected ? "true" : "false";
if (json.startsWith(s, pos)) {
pos += s.length();
return expected;
}
throw new ParseException("Expected " + s, pos);
}
private Object nil() {
if (json.startsWith("null", pos)) {
pos += 4;
return null;
}
throw new ParseException("Expected null", pos);
}
private Number number() {
int start = pos;
if (pos < json.length() && json.charAt(pos) == '-') pos++;
if (pos >= json.length()) throw new ParseException("Unexpected end of number", pos);
char c = json.charAt(pos);
if (c == '0') {
pos++;
} else if (c >= '1' && c <= '9') {
pos++;
while (pos < json.length() && isDigit(json.charAt(pos))) pos++;
} else {
throw new ParseException("Expected digit", pos);
}
boolean isDecimal = false;
if (pos < json.length() && json.charAt(pos) == '.') {
isDecimal = true;
pos++;
if (pos >= json.length() || !isDigit(json.charAt(pos))) {
throw new ParseException("Expected digit after '.'", pos);
}
while (pos < json.length() && isDigit(json.charAt(pos))) pos++;
}
if (pos < json.length() && (json.charAt(pos) == 'e' || json.charAt(pos) == 'E')) {
isDecimal = true;
pos++;
if (pos < json.length() && (json.charAt(pos) == '+' || json.charAt(pos) == '-')) pos++;
if (pos >= json.length() || !isDigit(json.charAt(pos))) {
throw new ParseException("Expected digit in exponent", pos);
}
while (pos < json.length() && isDigit(json.charAt(pos))) pos++;
}
String s = json.substring(start, pos);
if (isDecimal) return new BigDecimal(s);
try {
return Long.parseLong(s);
} catch (NumberFormatException e) {
return new BigInteger(s);
}
}
private void seek() {
while (pos < json.length() && Character.isWhitespace(json.charAt(pos))) pos++;
}
// This is a nitpick but it turns out that Character.isDigit() accepts a bunch of possible
// unicode digits as well as ASCII '0' through '9'. That's normally good, but not for
// parsing JSON! (Shoutout to P.H. -- "only ever permit ASCII" wins this one time.)
private static boolean isDigit(char c) {
return c >= '0' && c <= '9';
}
}
}
@@ -0,0 +1,337 @@
/*
* 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.formatting;
import java.math.BigInteger;
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 }
public enum Unit { GAJU, PUCK }
public record FormatSpec(Type type, Unit unit, char separator, int span) {}
public static FormatSpec standardSpec(Type type, Unit unit) {
return switch (type) {
case US -> new FormatSpec(type, unit, ',', 3);
case JP -> new FormatSpec(type, unit, ' ', 4);
case METRIC, LEGACY -> new FormatSpec(type, unit, ' ', 3);
};
}
private 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 "};
public static String amount(FormatSpec spec, byte[] puckBytes) {
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, 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");
};
}
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 amount(spec, pucks);
}
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(Locale.US, "%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(Locale.US, "%018d", divRem[1]);
puckStr = cleanTrailingZeros(puckStr);
if (puckStr.isEmpty()) {
return GAJU_MARK + sign + gajuStr;
}
return GAJU_MARK + sign + gajuStr + "." + chunkString(puckStr, spec.separator(), spec.span(), true);
}
private static String formatMyriad(FormatSpec spec, BigInteger absPucks, String[] ranks, boolean isNegative) {
String sign = isNegative ? "" : "";
if (spec.unit() == Unit.PUCK) {
return sign + processRanks(absPucks, ranks, 4, PUCK_MARK, false);
}
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, BigInteger absPucks, String[] ranks, boolean isNegative, String gSuffix, String pSuffix) {
String sign = isNegative ? "-" : "";
if (spec.unit() == Unit.PUCK) {
return PUCK_MARK + sign + processRanks(absPucks, ranks, 3, pSuffix, true);
}
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) {
char c = str.charAt(idx);
if (Character.isDigit(c)) {
if (c != '0') break;
}
idx--;
}
return idx < 0 ? "" : str.substring(0, idx + 1);
}
private static String chunkString(String str, char sep, int span, boolean isFraction) {
if (str.isEmpty()) return "";
StringBuilder sb = new StringBuilder();
int len = str.length();
for (int i = 0; i < len; i++) {
if (i > 0 && (isFraction ? i % span == 0 : (len - i) % span == 0)) {
sb.append(sep);
}
sb.append(str.charAt(i));
}
return sb.toString();
}
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();
BigInteger divisor = BigInteger.TEN.pow(span);
int rankIndex = 0;
BigInteger current = value;
while (current.compareTo(BigInteger.ZERO) > 0) {
BigInteger[] dr = current.divideAndRemainder(divisor);
long val = dr[1].longValue();
if (val > 0) {
String rank = ranks[rankIndex];
result.insert(0, val + rank);
}
current = dr[0];
rankIndex++;
}
String res = result.toString().trim();
return res + (useSpaces ? " " : "") + endingSymbol;
}
private static final Map<Character, BigInteger> MULTIPLIERS = new HashMap<>();
static {
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));
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 input");
String input = normalize(rawInput);
boolean isNegative = false;
if (input.startsWith("-") || input.startsWith("") || input.startsWith("")) {
isNegative = true;
input = input.substring(1).trim();
}
boolean forceGaju = input.startsWith(GAJU_MARK);
boolean forcePuck = input.startsWith(PUCK_MARK);
if (forceGaju || forcePuck) {
input = input.substring(1).trim();
}
BigInteger gajuTotal = BigInteger.ZERO;
BigInteger puckTotal = BigInteger.ZERO;
if (input.contains(".")) {
int dotIdx = input.indexOf('.');
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);
}
if (isNegative) puckTotal = puckTotal.negate();
return puckTotal.toByteArray();
}
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 >= '' && c <= '') {
sb.append((char) (c - '' + '0'));
} else if (c == '' || c == ',' || c == '_' || c == ' ' || c == '\u3000' || c == '\t' || c == '\n' || c == '\r') {
continue;
} else {
sb.append(c);
}
}
return sb.toString();
}
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);
if (Character.isDigit(c)) {
currentDigits.append(c);
} else if (MULTIPLIERS.containsKey(c)) {
if (currentDigits.length() == 0) continue;
BigInteger val = new BigInteger(currentDigits.toString()).multiply(MULTIPLIERS.get(c));
total = total.add(val);
currentDigits.setLength(0);
}
}
if (currentDigits.length() > 0) {
total = total.add(new BigInteger(currentDigits.toString()));
}
return total;
}
}
@@ -0,0 +1,73 @@
/*
* 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.tools;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
// Utility class for cryptographic operations and memory hygiene.
public final class CryptoUtils {
private CryptoUtils() {}
// Wipes a byte array by filling it with zeros.
public static void wipe(byte[] data) {
if (data != null) {
Arrays.fill(data, (byte) 0);
}
}
public static void wipe(long[] data) {
if (data != null) {
Arrays.fill(data, 0L);
}
}
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) {
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString();
}
public static byte[] hexToBin(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ Character.digit(s.charAt(i+1), 16));
}
return data;
}
}
@@ -0,0 +1,159 @@
/*
* 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.tools;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
// GRIDS URL handling, ported from hz_grids.erl.
public final class Grids {
private Grids() {}
public enum Verb {
SPEND,
TRANSFER,
SIGN
}
public enum Context {
CHAIN,
NODE,
HTTP,
HTTPS
}
public static final class ParseResult {
public final Verb verb;
public final Context context;
public final String location;
public final String recipient;
public final BigInteger amount;
public final byte[] payload;
public final String url; // For SIGN verb, the reconstructed URL
private ParseResult(Verb verb, Context context, String location, String recipient, BigInteger amount, byte[] payload, String url) {
this.verb = verb;
this.context = context;
this.location = location;
this.recipient = recipient;
this.amount = amount;
this.payload = payload;
this.url = url;
}
public static ParseResult spend(Context context, String location, String recipient, BigInteger amount, byte[] payload) {
return new ParseResult(Verb.SPEND, context, location, recipient, amount, payload, null);
}
public static ParseResult transfer(Context context, String location, String recipient, BigInteger amount, byte[] payload) {
return new ParseResult(Verb.TRANSFER, context, location, recipient, amount, payload, null);
}
public static ParseResult sign(Context context, String url) {
return new ParseResult(Verb.SIGN, context, null, null, null, null, url);
}
}
public static ParseResult parse(String gridsUrl) {
try {
URI uri = new URI(gridsUrl);
String scheme = uri.getScheme();
String host = uri.getHost();
String path = uri.getPath();
String query = uri.getQuery();
if (!"grids".equals(scheme) && !"grid".equals(scheme)) {
throw new IllegalArgumentException("Invalid scheme: " + scheme);
}
if (path.startsWith("/1/s/")) {
String recipient = path.substring(5);
Map<String, String> qargs = parseQuery(query);
String a = qargs.get("a");
BigInteger amount = (a == null || a.isEmpty()) ? BigInteger.ZERO : new BigInteger(a);
byte[] payload = decodePayload(qargs.getOrDefault("p", ""));
return ParseResult.spend(Context.CHAIN, host, recipient, amount, payload);
} else if (path.startsWith("/1/t/")) {
String recipient = path.substring(5);
Map<String, String> qargs = parseQuery(query);
String a = qargs.get("a");
BigInteger amount = (a == null || a.isEmpty()) ? BigInteger.ZERO : new BigInteger(a);
byte[] payload = decodePayload(qargs.getOrDefault("p", ""));
String location = host + (uri.getPort() != -1 ? ":" + uri.getPort() : "");
return ParseResult.transfer(Context.NODE, location, recipient, amount, payload);
} else if (path.startsWith("/1/d/")) {
String subPath = path.substring(4); // Includes the leading slash
String httpScheme = "grids".equals(scheme) ? "https" : "http";
String hostPart = host + (uri.getPort() != -1 ? ":" + uri.getPort() : "");
String httpUrl = httpScheme + "://" + hostPart + subPath + (query != null ? "?" + query : "");
return ParseResult.sign("grids".equals(scheme) ? Context.HTTPS : Context.HTTP, httpUrl);
} else {
throw new IllegalArgumentException("Unknown verb in path: " + path);
}
} catch (Exception e) {
throw new RuntimeException("Failed to parse GRIDS URL: " + e.getMessage(), e);
}
}
public static final String REQ_TYPE_MESSAGE = "message";
public static final String REQ_TYPE_BINARY = "binary";
public static final String REQ_TYPE_TX = "tx";
public static Map<String, Object> makeRequest(String type, Object payload, String publicId, String networkId) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("grids", 1);
req.put("chain", "gajumaru");
req.put("network_id", networkId);
req.put("type", type);
req.put("public_id", publicId);
req.put("payload", payload);
return req;
}
private static Map<String, String> parseQuery(String query) {
Map<String, String> params = new LinkedHashMap<>();
if (query == null || query.isEmpty()) return params;
String[] pairs = query.split("&");
for (String pair : pairs) {
int idx = pair.indexOf("=");
if (idx > 0) {
params.put(URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8),
URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8));
} else {
params.put(URLDecoder.decode(pair, StandardCharsets.UTF_8), "");
}
}
return params;
}
private static byte[] decodePayload(String p) {
if (p == null || p.isEmpty()) return new byte[0];
// The Erlang side uses uri_string:compose_query which percent-encodes.
// URLDecoder.decode should have handled it in parseQuery.
return p.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,108 @@
/*
* 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.tools;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import swiss.qpq.gajumaru.core.encoding.ZJ;
// Minimal HTTP client for GRIDS and Node interaction.
public final class Http {
private Http() {}
public static Object get(String url) throws Exception {
return request(url, "GET", null);
}
public static Object post(String url, Object body) throws Exception {
return request(url, "POST", body);
}
private static Object request(String urlStr, String method, Object body) throws Exception {
URL url = new java.net.URI(urlStr).toURL();
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
conn.setInstanceFollowRedirects(true);
// Emulate a standard browser agent to avoid being blocked by strict servers
conn.setRequestProperty("Accept", "application/json, text/plain, */*");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 10) GajuMobile/1.0");
if (body != null) {
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
byte[] input;
if (body instanceof String) {
input = ((String) body).getBytes(StandardCharsets.UTF_8);
} else {
input = ZJ.encode(body).getBytes(StandardCharsets.UTF_8);
}
os.write(input, 0, input.length);
}
}
int code = conn.getResponseCode();
StringBuilder response = new StringBuilder();
try (java.io.InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
response.append(line).append("\n");
}
}
String responseStr = response.toString().trim();
if (code < 200 || code >= 300) {
String reason = "HTTP " + code;
try {
Object decoded = ZJ.decode(responseStr);
if (decoded instanceof Map) {
Object r = ((Map<?, ?>) decoded).get("reason");
if (r != null) reason = r.toString();
}
} catch (Exception ignored) {}
throw new Exception(reason + " (URL: " + urlStr + ")");
}
if (responseStr.isEmpty()) return null;
try {
return ZJ.decode(responseStr);
} catch (Exception e) {
if (responseStr.startsWith("<")) {
throw new Exception("Server returned HTML instead of JSON (URL: " + urlStr + ")");
}
throw new Exception("JSON parse failed for " + urlStr + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,65 @@
/*
* 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.tools;
import swiss.qpq.gajumaru.core.crypto.Ed25519;
/**
* JavaSigningProvider implements SigningProvider using pure Java Ed25519 arithmetic.
*/
public final class JavaSigningProvider implements SigningProvider {
@Override
public byte[] cryptoSignSeedKeypair(byte[] seed) {
byte[] pub = Ed25519.publicKey(seed);
// Pure Java Ed25519 needs a way to get the expanded secret key.
// In Ed25519.sign it's: hash = sha512(seed); s = hash[0..31]; s[0] &= 248; s[31] &= 127; s[31] |= 64;
// Prefix is hash[32..63].
// Libsodium's 64-byte SK is often [32-byte seed | 32-byte public key].
// However, Ed25519.sign implementation expects the 64-byte expanded key to work correctly.
// Let's look at how Ed25519.sign works. It derives 's' and 'prefix' from the seed.
// For parity with Native implementation which returns [32pk | 64sk],
// we need to be careful about what 'sk' means.
// In libsodium, sk is [32-byte seed | 32-byte public key].
// In Ed25519.java, it uses seed directly.
// I will implement a bridge in Ed25519.java to handle this.
// FOR NOW: Assume we follow libsodium: SK = [seed(32) | pub(32)]
byte[] sk = new byte[64];
System.arraycopy(seed, 0, sk, 0, 32);
System.arraycopy(pub, 0, sk, 32, 32);
byte[] res = new byte[96];
System.arraycopy(pub, 0, res, 0, 32);
System.arraycopy(sk, 0, res, 32, 64);
return res;
}
@Override
public byte[] cryptoSignDetached(byte[] message, byte[] secretKey) {
// Assume secretKey is [seed(32) | pub(32)]
byte[] seed = new byte[32];
System.arraycopy(secretKey, 0, seed, 0, 32);
return Ed25519.sign(seed, message);
}
}
@@ -0,0 +1,128 @@
/*
* 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.tools;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
/**
* NodeClient provides a simple interface to query Gajumaru nodes.
* Ported from hz.erl
*/
public final class NodeClient {
public static final class Endpoint {
public final String host;
public final int port;
public final boolean useTls;
public Endpoint(String host, int port) {
this(host, port, false);
}
public Endpoint(String host, int port, boolean useTls) {
this.host = host;
this.port = port;
this.useTls = useTls;
}
}
private final List<Endpoint> endpoints;
public NodeClient(List<Endpoint> endpoints) {
if (endpoints == null || endpoints.isEmpty()) {
throw new IllegalArgumentException("At least one endpoint is required");
}
this.endpoints = endpoints;
}
@SuppressWarnings("unchecked")
public Map<String, Object> status() throws Exception {
return (Map<String, Object>) request("/v3/status", "GET", null);
}
@SuppressWarnings("unchecked")
public long topHeight() throws Exception {
Map<String, Object> res = (Map<String, Object>) request("/v3/headers/top", "GET", null);
return ((Number) res.get("height")).longValue();
}
@SuppressWarnings("unchecked")
public Map<String, Object> account(String accountId) throws Exception {
return (Map<String, Object>) request("/v3/accounts/" + accountId, "GET", null);
}
@SuppressWarnings("unchecked")
public long nextNonce(String accountId) throws Exception {
try {
Map<String, Object> res = (Map<String, Object>) request("/v3/accounts/" + accountId + "/next-nonce", "GET", null);
return ((Number) res.get("next_nonce")).longValue();
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().contains("Account not found")) {
return 1;
}
throw e;
}
}
@SuppressWarnings("unchecked")
public Map<String, Object> postTx(String signedTx) throws Exception {
Map<String, String> body = new HashMap<>();
body.put("tx", signedTx);
return (Map<String, Object>) request("/v3/transactions", "POST", body);
}
@SuppressWarnings("unchecked")
public Map<String, Object> tx(String txHash) throws Exception {
return (Map<String, Object>) request("/v3/transactions/" + txHash, "GET", null);
}
@SuppressWarnings("unchecked")
public Map<String, Object> txInfo(String txHash) throws Exception {
return (Map<String, Object>) request("/v3/transactions/" + txHash + "/info", "GET", null);
}
private Object request(String path, String method, Object body) throws Exception {
Exception lastException = null;
for (Endpoint endpoint : endpoints) {
try {
String protocol = endpoint.useTls ? "https://" : "http://";
String url = protocol + endpoint.host + ":" + endpoint.port + path;
if (endpoint.useTls) {
try {
return "GET".equals(method) ? Http.get(url) : Http.post(url, body);
} catch (Exception e) {
// Fallback to HTTP
String httpUrl = "http://" + endpoint.host + ":" + endpoint.port + path;
return "GET".equals(method) ? Http.get(httpUrl) : Http.post(httpUrl, body);
}
} else {
return "GET".equals(method) ? Http.get(url) : Http.post(url, body);
}
} catch (Exception e) {
lastException = e;
}
}
throw new Exception("All endpoints failed. Last error: " + (lastException != null ? lastException.getMessage() : "unknown"), lastException);
}
}
@@ -0,0 +1,43 @@
/*
* 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.tools;
/**
* SigningProvider abstracts Ed25519 operations to allow different implementations
* (e.g., pure Java vs. Native libsodium) to be used at runtime.
*/
public interface SigningProvider {
/**
* Expands a 32-byte seed into a 64-byte secret key and 32-byte public key.
* @param seed 32-byte seed.
* @return 96-byte array [public_key(32) | secret_key(64)].
*/
byte[] cryptoSignSeedKeypair(byte[] seed);
/**
* Signs a message using a 64-byte expanded secret key.
* @param message The data to sign.
* @param secretKey 64-byte secret key.
* @return 64-byte detached signature.
*/
byte[] cryptoSignDetached(byte[] message, byte[] secretKey);
}
@@ -0,0 +1,173 @@
/*
* 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.tools;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.List;
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.SignedTx;
import swiss.qpq.gajumaru.core.data.SpendTx;
import swiss.qpq.gajumaru.core.encoding.ApiEncoder;
/**
* TransactionService provides high-level tools to build and sign transactions
* and other chain data.
*/
public final class TransactionService {
private static SigningProvider provider = new JavaSigningProvider();
private TransactionService() {}
/**
* Sets the signing provider to use for all operations.
*/
public static void setProvider(SigningProvider newProvider) {
if (newProvider != null) {
provider = newProvider;
}
}
/**
* Builds and signs a Spend transaction using a 32-byte seed.
*/
public static String buildSpendTx(
String networkId,
String senderId,
String recipientId,
BigInteger amount,
BigInteger gasPrice,
BigInteger gas,
long ttl,
long nonce,
String payload,
byte[] seed
) {
// 1. Decode IDs
Id sender = new Id(Id.Tag.ACCOUNT, ApiEncoder.decode(senderId).payload());
ApiEncoder.DecodeResult recipientResult = ApiEncoder.decode(recipientId);
Id.Tag recipientTag = recipientResult.type() == ApiEncoder.Type.CONTRACT_PUBKEY ? Id.Tag.CONTRACT : Id.Tag.ACCOUNT;
Id recipient = new Id(recipientTag, recipientResult.payload());
// 2. Build SpendTx
byte[] payloadBytes = payload != null ? payload.getBytes(StandardCharsets.UTF_8) : new byte[0];
SpendTx tx = new SpendTx(sender, recipient, amount, gasPrice, gas, ttl, nonce, payloadBytes);
byte[] serializedTx = tx.serialize();
// 3. Prepare NetworkHash
byte[] networkIdBytes = networkId.getBytes(StandardCharsets.UTF_8);
byte[] txHash = Blake2b.hash(serializedTx, 32);
byte[] networkHash = new byte[networkIdBytes.length + txHash.length];
System.arraycopy(networkIdBytes, 0, networkHash, 0, networkIdBytes.length);
System.arraycopy(txHash, 0, networkHash, networkIdBytes.length, txHash.length);
// 4. Expand Seed and Sign
byte[] keypair = provider.cryptoSignSeedKeypair(seed);
if (keypair == null) throw new RuntimeException("Signing provider keypair generation failed");
byte[] secretKey = new byte[64];
System.arraycopy(keypair, 32, secretKey, 0, 64);
byte[] signature = provider.cryptoSignDetached(networkHash, secretKey);
CryptoUtils.wipe(keypair);
CryptoUtils.wipe(secretKey);
if (signature == null) throw new RuntimeException("Native signing failed");
// 5. Wrap in SignedTx
SignedTx signedTx = new SignedTx(List.of(signature), serializedTx);
return ApiEncoder.encode(ApiEncoder.Type.TRANSACTION, signedTx.serialize());
}
/**
* Signs a message using the Gajumaru standard (prefixed and hashed).
* @param secretKey 64-byte expanded secret key.
*/
public static byte[] signMessage(String message, byte[] secretKey) {
byte[] msgBytes = message.getBytes(StandardCharsets.UTF_8);
byte[] prefix = "Gajumaru Signed Message:\n".getBytes(StandardCharsets.UTF_8);
byte[] pSize = VarInt.encode(prefix.length);
byte[] mSize = VarInt.encode(msgBytes.length);
byte[] smashed = new byte[pSize.length + prefix.length + mSize.length + msgBytes.length];
int pos = 0;
System.arraycopy(pSize, 0, smashed, pos, pSize.length); pos += pSize.length;
System.arraycopy(prefix, 0, smashed, pos, prefix.length); pos += prefix.length;
System.arraycopy(mSize, 0, smashed, pos, mSize.length); pos += mSize.length;
System.arraycopy(msgBytes, 0, smashed, pos, msgBytes.length);
byte[] hashed = Blake2b.hash(smashed, 32);
return provider.cryptoSignDetached(hashed, secretKey);
}
/**
* Signs binary data using the Gajumaru standard (prefixed and hashed).
* @param secretKey 64-byte expanded secret key.
*/
public static byte[] signBinary(byte[] data, byte[] secretKey) {
byte[] prefix = "Gajumaru Signed Binary:".getBytes(StandardCharsets.UTF_8);
byte[] target = new byte[prefix.length + data.length];
System.arraycopy(prefix, 0, target, 0, prefix.length);
System.arraycopy(data, 0, target, prefix.length, data.length);
byte[] hashed = Blake2b.hash(target, 32);
return provider.cryptoSignDetached(hashed, secretKey);
}
/**
* Verifies a binary signature.
*/
public static boolean verifyBinary(byte[] data, byte[] signature, byte[] publicKey) {
byte[] prefix = "Gajumaru Signed Binary:".getBytes(StandardCharsets.UTF_8);
byte[] target = new byte[prefix.length + data.length];
System.arraycopy(prefix, 0, target, 0, prefix.length);
System.arraycopy(data, 0, target, prefix.length, data.length);
byte[] hashed = Blake2b.hash(target, 32);
return Ed25519.verify(publicKey, hashed, signature);
}
/**
* Signs a transaction.
* @param secretKey 64-byte expanded secret key.
*/
public static String signTx(byte[] txData, String networkId, byte[] secretKey) {
byte[] nidBytes = networkId.getBytes(StandardCharsets.UTF_8);
byte[] hash = Blake2b.hash(txData, 32);
byte[] networkHash = new byte[nidBytes.length + hash.length];
System.arraycopy(nidBytes, 0, networkHash, 0, nidBytes.length);
System.arraycopy(hash, 0, networkHash, nidBytes.length, hash.length);
byte[] signature = provider.cryptoSignDetached(networkHash, secretKey);
if (signature == null) throw new RuntimeException("Signing provider signing failed");
SignedTx signedTx = new SignedTx(List.of(signature), txData);
return ApiEncoder.encode(ApiEncoder.Type.TRANSACTION, signedTx.serialize());
}
}
@@ -0,0 +1,59 @@
/*
* 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.tools;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
// Bitcoin-style Variable-length integer encoding.
// Port of vencode in hz.erl.
public final class VarInt {
private VarInt() {}
public static byte[] encode(long n) {
if (n < 0) {
throw new IllegalArgumentException("Negative values not supported: " + n);
}
if (n < 0xfd) {
return new byte[]{(byte) n};
} else if (n <= 0xffff) {
ByteBuffer bb = ByteBuffer.allocate(3);
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put((byte) 0xfd);
bb.putShort((short) n);
return bb.array();
} else if (n <= 0xffffffffL) {
ByteBuffer bb = ByteBuffer.allocate(5);
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put((byte) 0xfe);
bb.putInt((int) n);
return bb.array();
} else {
ByteBuffer bb = ByteBuffer.allocate(9);
bb.order(ByteOrder.LITTLE_ENDIAN);
bb.put((byte) 0xff);
bb.putLong(n);
return bb.array();
}
}
}
@@ -13,3 +13,5 @@ doc/erlang.png
rel/example_project
.concrete/DEV_MODE
.rebar
.idea
*.iml
+53
View File
@@ -0,0 +1,53 @@
# GM Java <-> Erlang Tests
The core Java libraries are all written as transform functions over data.
There is little point, therefore, in obsessing over "unit tests" and "regression testing" of
Java code in Java when we have cannonical code in Erlang.
The purpose of this utility is to instead test the Java libraries agains the Erlang libraries
directly.
## How to run
In the current directory simply run `zx runlocal` and the report map will be printed to the screen
and random test data will be in the `temp/` directory.
To run a specific test suite run `zx runlocal [module names]`.
To list module names, run `zx runlocal list`.
## Where things are
The test cases are all generated in gmt.erl, generally based on randomized but valid data that
are fed to reference Erlang implementations. Java code is built using plain old javac, and is
handled by the `gajumaru-core/bin/compile` and `gajumaru-core/bin/run` scripts.
The input to and output from each test is recorded to disk in the `temp/` dir.
The inputs are then read in by equivalent Java functions and the output is then compared.
For a test case to pass the outputs must be identical.
## The Erlang execution context
`zx` manages loading the execution context, so dependencies like gmserialization, ec_utils,
base58, etc. (all of which are targets for which is increasingly turning into a Java port)
are brought in dynamically and managed by `zx`. The easiest way to test a specific function
in Java against the Erlang implementation is to add a call to it in gmt.erl and a matching
call in `../src/Testinator.java`.
The dependencies that are brought in can be listed with `zx list deps`.
The package names follow the pattern `[realm]-[package_name]-[version]`
The location of the sources varies on different systems.
- Linux: `$HOME/zomp/lib/[realm]/[package_name]/[version]/src`
- MacOS: `$HOME/.zx/zomp/lib/[realm]/[package_name]/[version]/src`
- Windows: `%%LOCALAPPDATA%%/zomp/lib/[realm]/[package_name]/[version]/src`
So for example `otpr-ec_utils-1.0.0` has its sources at `~/zomp/lib/otpr/ec_utils/1.0.0/src/`
on a reasonable system.
+570
View File
@@ -0,0 +1,570 @@
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.util.Arrays;
import java.util.Map;
import java.util.HashMap;
import java.math.BigInteger;
import swiss.qpq.gajumaru.core.encoding.Base58;
import swiss.qpq.gajumaru.core.encoding.RLP;
import swiss.qpq.gajumaru.core.encoding.ApiEncoder;
import swiss.qpq.gajumaru.core.encoding.ZJ;
import swiss.qpq.gajumaru.core.formatting.GajuFormat;
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.encoding.Mnemonic;
import swiss.qpq.gajumaru.core.tools.CryptoUtils;
import swiss.qpq.gajumaru.core.tools.Grids;
public class Testinator {
public static void main(String[] args) {
if (args.length < 2) {
System.out.println("Error: Provide the test suite name and the required arguments.");
System.exit(1);
}
try {
switch (args[0]) {
case "base64" -> { System.out.print(base64(args[1])); }
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])); }
case "keccak256" -> { System.out.print(keccak256(args[1])); }
case "blake2b" -> { System.out.print(blake2b(args[1])); }
case "ed25519" -> { System.out.print(ed25519(args[1])); }
case "ed25519_verify" -> { System.out.print(ed25519_verify(args[1])); }
case "ed25519_malleability" -> { System.out.print(ed25519_malleability()); }
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 "mnemonic" -> { System.out.print(mnemonic(args[1])); }
case "zj_encode" -> { System.out.print(zj_encode(args[1])); }
case "zj_decode" -> { System.out.print(zj_decode(args[1])); }
case "grids_parse" -> { System.out.print(grids_parse(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])); }
case "femul_parity" -> { System.out.print(femul_parity(args[1])); }
case "frombytes_parity" -> { System.out.print(frombytes_parity(args[1])); }
case "ge_parity" -> { System.out.print(ge_parity(args[1], args[2])); }
case "keccak" -> {
byte[] in = CryptoUtils.hexToBin(args[1]);
System.out.println(CryptoUtils.binToHex(Keccak256.hash(in)));
}
case "blake2b_direct" -> {
byte[] in = CryptoUtils.hexToBin(args[1]);
System.out.println(CryptoUtils.binToHex(Blake2b.hash(in)));
}
case "ak_encode" -> {
byte[] in = CryptoUtils.hexToBin(args[1]);
System.out.println(ApiEncoder.encode(ApiEncoder.Type.ACCOUNT_PUBKEY, in));
}
case "ed25519_pub" -> {
byte[] seed = CryptoUtils.hexToBin(args[1]);
System.out.println(CryptoUtils.binToHex(Ed25519.publicKey(seed)));
}
case "id_serialize" -> {
int tag = Integer.parseInt(args[1]);
byte[] val = CryptoUtils.hexToBin(args[2]);
Id id = new Id(Id.Tag.fromValue(tag), val);
System.out.println(CryptoUtils.binToHex(id.serialize()));
}
default -> {
System.out.println("Error: Unknown test suite or command: " + args[0]);
System.exit(1);
}
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
e.printStackTrace();
System.exit(1);
}
}
private static String ge_parity(String aHex, String bHex) throws IOException {
byte[] a = CryptoUtils.hexToBin(aHex);
byte[] b = CryptoUtils.hexToBin(bHex);
Ed25519.Scratch sc = new Ed25519.Scratch();
Ed25519.Ge p1 = Ed25519.scalarMulBase(a, sc);
Ed25519.Ge p2 = Ed25519.scalarMulBase(b, sc);
Ed25519.Ge p3 = new Ed25519.Ge();
Ed25519.ge_add(p3, p1, p2, sc);
byte[] res = Ed25519.compress(p3, sc);
p1.wipe(); p2.wipe(); p3.wipe(); sc.wipe();
return CryptoUtils.binToHex(res) + "|||";
}
private static String frombytes_parity(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "frombytes.test");
Path resPath = Path.of(workingPath, "frombytes.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String hex : lines) {
if (hex.trim().isEmpty()) continue;
byte[] in = CryptoUtils.hexToBin(hex);
long[] h = new long[10];
Ed25519.fe_frombytes(h, in);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
if (i > 0) sb.append(",");
sb.append(h[i]);
}
results.add(sb.toString());
}
Files.write(resPath, results);
return resPath.toString();
}
private static String femul_parity(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "femul.test");
Path resPath = Path.of(workingPath, "femul.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String line : lines) {
if (line.trim().isEmpty()) continue;
String[] parts = line.split("\\|");
byte[] a = CryptoUtils.hexToBin(parts[0]);
byte[] b = CryptoUtils.hexToBin(parts[1]);
long[] ha = new long[10], hb = new long[10];
Ed25519.fe_frombytes(ha, a);
Ed25519.fe_frombytes(hb, b);
long[] hr = new long[10];
Ed25519.fe_mul(hr, ha, hb, new long[19]);
byte[] out = Ed25519.fe_contract(hr);
results.add(CryptoUtils.binToHex(out));
}
Files.write(resPath, results);
return resPath.toString();
}
private static String smb_parity(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "smb.test");
Path resPath = Path.of(workingPath, "smb.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String hex : lines) {
if (hex.trim().isEmpty()) continue;
byte[] in = CryptoUtils.hexToBin(hex);
Ed25519.Scratch sc = new Ed25519.Scratch();
Ed25519.Ge p = Ed25519.scalarMulBase(in, sc);
byte[] out = Ed25519.compress(p, sc);
p.wipe(); sc.wipe();
results.add(CryptoUtils.binToHex(out));
}
Files.write(resPath, results);
return resPath.toString();
}
private static String reduce_parity(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "reduce.test");
Path resPath = Path.of(workingPath, "reduce.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String hex : lines) {
if (hex.trim().isEmpty()) continue;
byte[] in = CryptoUtils.hexToBin(hex);
byte[] s = Arrays.copyOf(in, 64);
Ed25519.reduceScalar(s);
byte[] out = Arrays.copyOf(s, 32);
results.add(CryptoUtils.binToHex(out));
}
Files.write(resPath, results);
return resPath.toString();
}
private static String fe_parity(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "fe.test");
Path resPath = Path.of(workingPath, "fe.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String hex : lines) {
if (hex.trim().isEmpty()) continue;
byte[] in = CryptoUtils.hexToBin(hex);
long[] h = new long[10];
Ed25519.fe_frombytes(h, in);
byte[] out = Ed25519.fe_contract(h);
results.add(CryptoUtils.binToHex(out));
}
Files.write(resPath, results);
return resPath.toString();
}
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[] 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[] 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 resPath = Path.of(workingPath, "rlp.java.back");
byte[] encB = Files.readAllBytes(testPath);
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<String> 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<String> lines = Files.readAllLines(testPath);
List<String> 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();
}
private static String keccak256(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "keccak256.test");
Path resPath = Path.of(workingPath, "keccak256.java.txt");
byte[] input = Files.readAllBytes(testPath);
byte[] hash = Keccak256.hash(input);
Files.write(resPath, CryptoUtils.binToHex(hash).getBytes());
return resPath.toString();
}
private static String blake2b(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "blake2b.test");
Path resPath = Path.of(workingPath, "blake2b.java.txt");
byte[] input = Files.readAllBytes(testPath);
byte[] hash = Blake2b.hash(input);
Files.write(resPath, CryptoUtils.binToHex(hash).getBytes());
return resPath.toString();
}
private static String ed25519(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "ed25519.test");
Path resPath = Path.of(workingPath, "ed25519.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String line : lines) {
if (line.trim().isEmpty()) continue;
String[] parts = line.split("\\|");
byte[] seed = CryptoUtils.hexToBin(parts[0]);
byte[] msg = CryptoUtils.hexToBin(parts[1]);
byte[] pub = Ed25519.publicKey(seed);
byte[] sig = Ed25519.sign(seed, msg);
boolean verify = Ed25519.verify(pub, msg, sig);
results.add(CryptoUtils.binToHex(pub) + "|" + CryptoUtils.binToHex(sig) + "|" + verify);
CryptoUtils.wipe(seed);
CryptoUtils.wipe(msg);
CryptoUtils.wipe(pub);
CryptoUtils.wipe(sig);
}
Files.write(resPath, results);
return resPath.toString();
}
private static String ed25519_verify(String workingPath) throws IOException {
Path seedPath = Path.of(workingPath, "ed25519_seed.test");
Path msgPath = Path.of(workingPath, "ed25519_msg.test");
byte[] seed = CryptoUtils.hexToBin(Files.readString(seedPath).trim());
byte[] message = Files.readAllBytes(msgPath);
byte[] pub = Ed25519.publicKey(seed);
byte[] sig = Ed25519.sign(seed, message);
boolean verify = Ed25519.verify(pub, message, sig);
return CryptoUtils.binToHex(pub) + "|" + CryptoUtils.binToHex(sig) + "|" + verify;
}
private static String ed25519_malleability() {
// L in little-endian
byte[] L_bytes = {
(byte) 0xed, (byte) 0xd3, (byte) 0xf5, (byte) 0x5c, (byte) 0x1a, (byte) 0x63, (byte) 0x12, (byte) 0x58,
(byte) 0xd6, (byte) 0x9c, (byte) 0xf7, (byte) 0xa2, (byte) 0xde, (byte) 0xf9, (byte) 0xde, (byte) 0x14,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00,
(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x10
};
byte[] sig = new byte[64];
System.arraycopy(L_bytes, 0, sig, 32, 32); // S = L
boolean result = Ed25519.verify(new byte[32], new byte[0], sig);
return Boolean.toString(result);
}
private static String api_encode(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "api_encode.test");
Path resPath = Path.of(workingPath, "api_encode.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("\\|");
ApiEncoder.Type type = ApiEncoder.Type.valueOf(p[0]);
byte[] payload = CryptoUtils.hexToBin(p[1]);
results.add(ApiEncoder.encode(type, payload));
if (type == ApiEncoder.Type.ACCOUNT_SECKEY) {
CryptoUtils.wipe(payload);
}
}
Files.write(resPath, results);
return resPath.toString();
}
private static String id_serialization(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "id_serialization.test");
Path resPath = Path.of(workingPath, "id_serialization.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("\\|");
Id.Tag tag = Id.Tag.fromValue(Integer.parseInt(p[0]));
byte[] val = CryptoUtils.hexToBin(p[1]);
Id id = new Id(tag, val);
results.add(CryptoUtils.binToHex(id.serialize()));
}
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();
}
private static String mnemonic(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "mnemonic.test");
Path resPath = Path.of(workingPath, "mnemonic.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String hex : lines) {
if (hex.trim().isEmpty()) continue;
byte[] seed = CryptoUtils.hexToBin(hex);
byte[][] phrase = Mnemonic.encode(seed);
// Join words with space
StringBuilder sb = new StringBuilder();
for (int i = 0; i < phrase.length; i++) {
if (i > 0) sb.append(" ");
sb.append(new String(phrase[i], java.nio.charset.StandardCharsets.UTF_8));
}
results.add(sb.toString());
// Roundtrip test
byte[] decoded = Mnemonic.decode(phrase);
if (!java.util.Arrays.equals(seed, decoded)) {
throw new RuntimeException("Mnemonic roundtrip failed in Java!");
}
CryptoUtils.wipe(seed);
for (byte[] word : phrase) CryptoUtils.wipe(word);
}
Files.write(resPath, results);
return resPath.toString();
}
private static String zj_encode(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "zj_encode.test");
Path resPath = Path.of(workingPath, "zj_encode.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String line : lines) {
if (line.trim().isEmpty()) continue;
// For testing, we decode then re-encode to check consistency
Object val = ZJ.decode(line);
results.add(ZJ.encode(val));
}
Files.write(resPath, results);
return resPath.toString();
}
private static String zj_decode(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "zj_decode.test");
Path resPath = Path.of(workingPath, "zj_decode.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String line : lines) {
if (line.trim().isEmpty()) continue;
Object val = ZJ.decode(line);
// We encode it back to string for comparison or output format
results.add(ZJ.encode(val));
}
Files.write(resPath, results);
return resPath.toString();
}
private static String grids_parse(String workingPath) throws IOException {
Path testPath = Path.of(workingPath, "grids_parse.test");
Path resPath = Path.of(workingPath, "grids_parse.java.txt");
List<String> lines = Files.readAllLines(testPath);
List<String> results = new ArrayList<>();
for (String url : lines) {
if (url.trim().isEmpty()) continue;
Grids.ParseResult res = Grids.parse(url);
Map<String, Object> map = new HashMap<>();
map.put("verb", res.verb.name());
map.put("context", res.context.name());
map.put("location", res.location);
map.put("recipient", res.recipient);
map.put("amount", res.amount != null ? res.amount.toString() : null);
map.put("payload", res.payload != null ? CryptoUtils.binToHex(res.payload) : null);
map.put("url", res.url);
results.add(ZJ.encode(map));
}
Files.write(resPath, results);
return resPath.toString();
}
}
+676
View File
@@ -0,0 +1,676 @@
%%% @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).
-vsn("0.1.0").
-author("Craig Everett <craigeverett@qpq.swiss>").
-copyright("Craig Everett <craigeverett@qpq.swiss>").
-license("LGPL-3.0-or-later").
-export([start/1]).
%%% Logic
mods() ->
#{"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,
"keccak256" => fun keccak256/0,
"blake2b" => fun blake2b/0,
"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,
"mnemonic" => fun mnemonic/0,
"fe_parity" => fun fe_parity/0,
"reduce_parity" => fun reduce_parity/0,
"smb_parity" => fun smb_parity/0,
"femul_parity" => fun femul_parity/0,
"frombytes_parity" => fun frombytes_parity/0,
"ge_parity" => fun ge_parity/0}.
start([]) ->
Tests = mods(),
ok = run(Tests),
zx:silent_stop();
start(["list"]) ->
ok = io:format("Available tests:~n"),
ok = lists:foreach(fun display/1, maps:keys(mods())),
zx:silent_stop();
start(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(Available)),
ok = io:format("The following arguments are not testable module names:~n"),
lists:foreach(fun display/1, NotMods)
end,
zx:silent_stop().
display(Name) ->
io:format(" ~ts~n", [Name]).
run(Tests) ->
{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),
io:format("~nFinal Results:~n ~tp~n", [Results]).
run(Name, Test) ->
ok = io:format("~nRunning: ~ts...~n", [Name]),
Test().
clean() ->
Temp = "test/temp",
lists:foreach(fun(D) -> ok = clean(D) end, [Temp]).
clean(Dir) ->
case file:del_dir_r(Dir) of
ok -> ok;
{error, enoent} -> ok;
Error -> Error
end.
build() ->
Out = os:cmd("bin/compile"),
io:format("Compile: ~ts", [Out]).
temp_dir() ->
{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() ->
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, B} = file:read_file(TestFile),
Base64 = base64:encode(B),
ok = file:write_file(ConvFile, Base64),
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() ->
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, B} = file:read_file(TestFile),
Base58 = base58:binary_to_base58(B),
ok = file:write_file(ConvFile, Base58),
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.
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(<<B/binary, Checksum/binary>>),
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)),
[rand:bytes(rand:uniform(20)),
Anchor,
rand:bytes(rand:uniform(20)),
rand:bytes(rand:uniform(5000))],
rand:bytes(rand:uniform(2000))],
RLP = gmser_rlp:encode(Data),
RLP_File = filename:join(Temp, "rlp.test"),
ok = filelib:ensure_dir(RLP_File),
ok = file:write_file(RLP_File, RLP),
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} ->
ok = io:format("Failed to read RLP Java result: ~tp (Path: ~tp)~n", [R, JPathTrimmed]),
false
end;
_ ->
ok = io:format("RLP output mismatch: ~tp~n", [Out]),
false
end.
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} ->
Format = "Failed to read Java results from: ~tp (Reason: ~tp)~nRaw Output: ~ts~n",
io:format(Format, [JavaResPath, Reason, RawOut]),
false
end.
keccak256() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "keccak256.test"),
ResFile = filename:join(Temp, "keccak256.erlang.txt"),
Data = rand:bytes(rand:uniform(5000)),
ok = filelib:ensure_dir(TestFile),
ok = file:write_file(TestFile, Data),
Hash = sha3:hash(256, Data),
HexHash = bin_to_hex(Hash),
ok = file:write_file(ResFile, HexHash),
Run = "bin/run keccak256 " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOut} = file:read_file(trim(Out)),
unicode:characters_to_list(JOut) =:= HexHash.
blake2b() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "blake2b.test"),
ResFile = filename:join(Temp, "blake2b.erlang.txt"),
Data = rand:bytes(rand:uniform(5000)),
ok = filelib:ensure_dir(TestFile),
ok = file:write_file(TestFile, Data),
{ok, Hash} = eblake2:blake2b(32, Data),
HexHash = bin_to_hex(Hash),
ok = file:write_file(ResFile, HexHash),
Run = "bin/run blake2b " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOut} = file:read_file(trim(Out)),
unicode:characters_to_list(JOut) =:= HexHash.
ed25519() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "ed25519.test"),
ok = filelib:ensure_dir(TestFile),
Cases = [{rand:bytes(32), rand:bytes(rand:uniform(100))} || _ <- lists:seq(1, 20)],
ok = file:write_file(TestFile, [[bin_to_hex(S), "|", bin_to_hex(M), "\n"] || {S, M} <- Cases]),
Sequence =
fun({S, M}) ->
#{public := Pub} = ecu_eddsa:sign_seed_keypair(S),
Sig = ecu_eddsa:sign_detached(M, S),
V = ecu_eddsa:sign_verify_detached(Sig, M, Pub),
lists:flatten(io_lib:format("~s|~s|~p", [bin_to_hex(Pub), bin_to_hex(Sig), V]))
end,
Expected = lists:map(Sequence, Cases),
Run = "bin/run ed25519 " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOutContent} = file:read_file(trim(Out)),
JResults = [trim(L) || L <- string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all)],
same_same(Expected, JResults).
api_encode() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "api_encode.test"),
Types = [account_pubkey, account_seckey, tx_hash, contract_pubkey, signature, commitment, peer_pubkey],
Cases = [{T, rand:bytes(type_size(T))} || T <- Types],
Lines = [io_lib:format("~ts|~ts", [string:uppercase(atom_to_list(T)), bin_to_hex(B)]) || {T, B} <- Cases],
ok = filelib:ensure_dir(TestFile),
ok = file:write_file(TestFile, unicode:characters_to_binary([[L, "\n"] || L <- Lines])),
Expected = [flatten(gmser_api_encoder:encode(T, B)) || {T, B} <- Cases],
Run = "bin/run api_encode " ++ 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).
type_size(signature) -> 64;
type_size(_) -> 32.
id_serialization() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "id_serialization.test"),
Tags = [1, 2, 3, 5, 6, 7, 9],
Cases = [{Tag, rand:bytes(32)} || Tag <- Tags],
Lines = [io_lib:format("~b|~ts", [Tag, bin_to_hex(B)]) || {Tag, B} <- Cases],
ok = filelib:ensure_dir(TestFile),
ok = file:write_file(TestFile, unicode:characters_to_binary([[L, "\n"] || L <- Lines])),
Expected = [bin_to_hex(gmser_id:encode(gmser_id:decode(<<T:8, B/binary>>))) || {T, B} <- Cases],
Run = "bin/run id_serialization " ++ 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() ->
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.
mnemonic() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "mnemonic.test"),
ok = filelib:ensure_dir(TestFile),
Cases = [rand:bytes(32) || _ <- lists:seq(1, 20)],
ok = file:write_file(TestFile, [[bin_to_hex(S), "\n"] || S <- Cases]),
Expected = [unicode:characters_to_list(hz_key_master:encode(S)) || S <- Cases],
Run = "bin/run mnemonic " ++ 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).
fe_parity() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "fe.test"),
ok = filelib:ensure_dir(TestFile),
% Mask to 255 bits to avoid bit-255 sign bit ambiguity in fe_frombytes
RandomLittleFingers =
fun() ->
<<B:256/little>> = rand:bytes(32),
bin_to_hex(<<(B band ((1 bsl 255) - 1)):256/little>>)
end,
Inputs = [RandomLittleFingers() || _ <- lists:seq(1, 100)],
ok = file:write_file(TestFile, unicode:characters_to_binary([[I, "\n"] || I <- Inputs])),
Run = "bin/run fe_parity " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOutContent} = file:read_file(trim(Out)),
JResults = string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all),
P = ecu_ed25519:p(),
Pee =
fun(I) ->
<<B:256/little>> = hex_to_bin(I),
bin_to_hex(pack_p(B rem P))
end,
Expected = lists:map(Pee, Inputs),
same_same(Expected, JResults).
reduce_parity() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "reduce.test"),
ok = filelib:ensure_dir(TestFile),
Inputs = [rand:bytes(64) || _ <- lists:seq(1, 100)],
ok = file:write_file(TestFile, unicode:characters_to_binary([[bin_to_hex(I), "\n"] || I <- Inputs])),
Run = "bin/run reduce_parity " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOutContent} = file:read_file(trim(Out)),
JResults = string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all),
Expected = [bin_to_hex(ecu_ed25519:scalar_reduce(I)) || I <- Inputs],
same_same(Expected, JResults).
smb_parity() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "smb.test"),
ok = filelib:ensure_dir(TestFile),
Inputs = [<<(rand:bytes(31))/binary, 0>> || _ <- lists:seq(1, 10)],
ok = file:write_file(TestFile, unicode:characters_to_binary([[bin_to_hex(I), "\n"] || I <- Inputs])),
Run = "bin/run smb_parity " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOutContent} = file:read_file(trim(Out)),
JResults = string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all),
Expected = [bin_to_hex(ecu_ed25519:compress(ecu_ed25519:scalar_mul_base_noclamp(I))) || I <- Inputs],
same_same(Expected, JResults).
femul_parity() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "femul.test"),
ok = filelib:ensure_dir(TestFile),
% Mask to 255 bits
Gen = fun() -> <<B:256/little>> = rand:bytes(32), << (B band ((1 bsl 255) - 1)):256/little >> end,
Inputs = [{Gen(), Gen()} || _ <- lists:seq(1, 10)],
Lines = [bin_to_hex(A) ++ "|" ++ bin_to_hex(B) || {A, B} <- Inputs],
ok = file:write_file(TestFile, unicode:characters_to_binary([[L, "\n"] || L <- Lines])),
Run = "bin/run femul_parity " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOutContent} = file:read_file(trim(Out)),
JResults = string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all),
ExpectedBin = [bin_to_hex(pack_p(ecu_ed25519:f_mul(binary:decode_unsigned(A, little), binary:decode_unsigned(B, little)))) || {A, B} <- Inputs],
same_same3(Inputs, ExpectedBin, JResults).
same_same3(AB, L1, L2) ->
same_same3(AB, L1, L2, true).
same_same3([_ | R1], [S | R2], [S | R3], Result) ->
same_same3(R1, R2, R3, Result);
same_same3([{A, B} | R1], [E | R2], [J | R3], _) ->
ok = io:format("A: ~ts~nB: ~ts~nE: ~ts~nJ: ~ts~n", [bin_to_hex(A), bin_to_hex(B), E, J]),
same_same3(R1, R2, R3, false);
same_same3([], [], [], Result) ->
Result.
same_same(L1, L2) ->
same_same(L1, L2, true).
same_same([S | R1], [S | R2], Result) ->
same_same(R1, R2, Result);
same_same([E | R1], [J | R2], _) ->
ok = io:format("Mismatch!\nE: ~ts\nJ: ~ts\n", [E, J]),
same_same(R1, R2, false);
same_same([], [], Result) ->
Result.
frombytes_parity() ->
Temp = temp_dir(),
TestFile = filename:join(Temp, "frombytes.test"),
ok = filelib:ensure_dir(TestFile),
Inputs = [rand:bytes(32) || _ <- lists:seq(1, 10)],
ok = file:write_file(TestFile, unicode:characters_to_binary([[bin_to_hex(I), "\n"] || I <- Inputs])),
Run = "bin/run frombytes_parity " ++ Temp,
Out = trim(os:cmd(Run)),
{ok, JOutContent} = file:read_file(trim(Out)),
JResults = string:split(trim(unicode:characters_to_list(JOutContent)), "\n", all),
Limb =
fun(I) ->
Val = binary:decode_unsigned(I, little),
Limbs =
fun
F(V, Idx) when Idx < 10 ->
Size =
case Idx rem 2 =:= 0 of
true -> 26;
false -> 25
end,
L = V band ((1 bsl Size) - 1),
[L | F(V bsr Size, Idx + 1)];
F(_, _) ->
[]
end,
string:join([integer_to_list(L) || L <- Limbs(Val, 0)], ",")
end,
Expected = lists:map(Limb, Inputs),
same_same(Expected, JResults).
ge_parity() ->
A_bin = <<1, 2, 3, 0:232>>,
B_bin = <<4, 5, 6, 0:232>>,
P1_erl = ecu_ed25519:scalar_mul_base_noclamp(A_bin),
P2_erl = ecu_ed25519:scalar_mul_base_noclamp(B_bin),
P3_erl = ecu_ed25519:p_add(P1_erl, P2_erl),
E_comp = bin_to_hex(ecu_ed25519:compress(P3_erl)),
Run = "bin/run ge_parity " ++ bin_to_hex(A_bin) ++ " " ++ bin_to_hex(B_bin),
Out = trim(os:cmd(Run)),
case string:split(Out, "|||") of
[JX | _] -> E_comp =:= trim(JX);
_ -> false
end.
pack_p(V) ->
P = (1 bsl 255) - 19,
V_pos = if V < 0 -> V + P; true -> V rem P end,
Enc = binary:encode_unsigned(V_pos, little),
Size = byte_size(Enc),
if Size < 32 -> <<Enc/binary, 0:(8*(32-Size))>>; true -> Enc end.
bin_to_hex(Bin) ->
lists:flatten([io_lib:format("~2.16.0b", [X]) || X <- binary_to_list(Bin)]).
hex_to_bin(S) ->
hex_to_bin(S, []).
hex_to_bin([], Acc) ->
list_to_binary(lists:reverse(Acc));
hex_to_bin([X,Y|T], Acc) ->
{ok, [V], []} = io_lib:fread("~16u", [X,Y]),
hex_to_bin(T, [V | Acc]).
%%% 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 = hz_format:amount(Unit, hz_style(Style, Sep, Span), Pucks),
{amount, Style, Unit, Sep, Span, Pucks, Amount};
gen_case(approx) ->
Pucks = random_pucks(12),
Sep = lists:nth(rand:uniform(2), [$,, $_]),
Span = rand:uniform(2) + 2,
Prec = rand:uniform(18),
Approx = hz_format:approx_amount({Sep, Span}, Prec, Pucks),
{approx, us, gaju, Sep, Span, Pucks, Prec, Approx}.
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, _}) ->
FStyle = string:uppercase(atom_to_list(Style)),
FUnit = string:uppercase(atom_to_list(Unit)),
Stuff = [FStyle, FUnit, Sep, Span, Pucks],
io_lib:format("amount|~ts|~ts|~c|~b|~b", Stuff);
serialize_case({approx, Style, Unit, Sep, Span, Pucks, Prec, _}) ->
FStyle = string:uppercase(atom_to_list(Style)),
FUnit = string:uppercase(atom_to_list(Unit)),
Stuff = [FStyle, FUnit, Sep, Span, Pucks, Prec],
io_lib:format("approx|~ts|~ts|~c|~b|~b|~b", Stuff);
serialize_case({read, Input, _}) ->
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).
@@ -6,7 +6,8 @@
{author,"Craig Everett"}.
{desc,"An inter-language test suite for Gajumaru core libs"}.
{package_id,{"qpq","gm_libtester",{0,1,0}}}.
{deps,[{"otpr","hakuzaru",{0,9,1}},
{deps,[{"otpr","sha3",{0,1,4}},
{"otpr","hakuzaru",{0,9,1}},
{"otpr","getopt",{1,0,2}},
{"otpr","zj",{1,1,0}},
{"otpr","ec_utils",{1,0,0}},