Add ASN.1-to-Java codegen and RLP codec for spend/signed tx subset.

Generate typed records from GajumaruChainObjects.asn, implement Asn1Rlp
and BasicEncoders matching Erlang static serialization, and add golden-vector
equivalence tests against gmser_chain_objects output.
This commit is contained in:
Ulf Wiger
2026-07-22 11:31:15 +02:00
parent 9492d193d6
commit 53eaa3056a
9 changed files with 1144 additions and 2 deletions
@@ -0,0 +1,15 @@
package swiss.qpq.gajumaru.core.asn1;
/*
* Generated by bin/asn1_to_java from GajumaruChainObjects.asn.
* Do not edit by hand — regenerate from the ASN.1 schema.
*
* These types are abstract syntax (headers). Wire encoding is RLP
* via swiss.qpq.gajumaru.core.encoding.RLP / Asn1Rlp, not BER/DER.
*/
public record Id(
int type,
byte[] value
) {
}
@@ -0,0 +1,20 @@
package swiss.qpq.gajumaru.core.asn1;
/*
* Generated by bin/asn1_to_java from GajumaruChainObjects.asn.
* Do not edit by hand — regenerate from the ASN.1 schema.
*
* These types are abstract syntax (headers). Wire encoding is RLP
* via swiss.qpq.gajumaru.core.encoding.RLP / Asn1Rlp, not BER/DER.
*/
public record SignedTxV1(
int tag,
int vsn,
java.util.List<byte[]> signatures,
byte[] transaction
) {
public static final int TAG = 11;
public static final int VSN = 1;
}
@@ -0,0 +1,26 @@
package swiss.qpq.gajumaru.core.asn1;
/*
* Generated by bin/asn1_to_java from GajumaruChainObjects.asn.
* Do not edit by hand — regenerate from the ASN.1 schema.
*
* These types are abstract syntax (headers). Wire encoding is RLP
* via swiss.qpq.gajumaru.core.encoding.RLP / Asn1Rlp, not BER/DER.
*/
public record SpendTxV1(
int tag,
int vsn,
Id senderId,
Id recipientId,
java.math.BigInteger amount,
java.math.BigInteger gasPrice,
java.math.BigInteger gas,
java.math.BigInteger ttl,
java.math.BigInteger nonce,
byte[] payload
) {
public static final int TAG = 12;
public static final int VSN = 1;
}
@@ -0,0 +1,213 @@
/*
* 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.
*
* SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial
*/
package swiss.qpq.gajumaru.core.serialization;
import swiss.qpq.gajumaru.core.asn1.Id;
import swiss.qpq.gajumaru.core.asn1.SignedTxV1;
import swiss.qpq.gajumaru.core.asn1.SpendTxV1;
import swiss.qpq.gajumaru.core.encoding.RLP;
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;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* Thin RLP production / consumption for ASN.1-shaped chain objects.
*
* <p>Mirrors {@code gmser_asn1_rlp} / {@code gmserialization}: walk typed
* values (generated from the ASN.1 schema) and emit legacy RLP lists of the
* form {@code [tag, vsn | fields…]}. Field names never appear on the wire.
*
* <p>Note on {@link Id}: the ASN.1 model is a SEQUENCE of type+value, but the
* RLP wire form is the 33-byte {@code gmser_id} encoding (a single RLP string).
*
* <p>Scope for now: {@link SpendTxV1}, {@link SignedTxV1}. More types can be
* added as generated records appear.
*/
public final class Asn1Rlp {
private Asn1Rlp() {}
// ------------------------------------------------------------------
// Encode
// ------------------------------------------------------------------
public static byte[] encode(SpendTxV1 tx) {
return RLP.encode(toRlp(tx));
}
public static byte[] encode(SignedTxV1 tx) {
return RLP.encode(toRlp(tx));
}
public static RLP_Data toRlp(SpendTxV1 tx) {
require(tx, "SpendTxV1");
if (tx.tag() != SpendTxV1.TAG || tx.vsn() != SpendTxV1.VSN) {
throw new IllegalArgumentException(
"SpendTxV1 tag/vsn mismatch: got " + tx.tag() + "/" + tx.vsn()
+ ", expected " + SpendTxV1.TAG + "/" + SpendTxV1.VSN);
}
List<RLP_Data> items = new ArrayList<>(10);
items.add(item(BasicEncoders.encodeUint(tx.tag())));
items.add(item(BasicEncoders.encodeUint(tx.vsn())));
items.add(item(BasicEncoders.encodeId(tx.senderId())));
items.add(item(BasicEncoders.encodeId(tx.recipientId())));
items.add(item(BasicEncoders.encodeUint(tx.amount())));
items.add(item(BasicEncoders.encodeUint(tx.gasPrice())));
items.add(item(BasicEncoders.encodeUint(tx.gas())));
items.add(item(BasicEncoders.encodeUint(tx.ttl())));
items.add(item(BasicEncoders.encodeUint(tx.nonce())));
items.add(item(nullToEmpty(tx.payload())));
return new RLP_List(items);
}
public static RLP_Data toRlp(SignedTxV1 tx) {
require(tx, "SignedTxV1");
if (tx.tag() != SignedTxV1.TAG || tx.vsn() != SignedTxV1.VSN) {
throw new IllegalArgumentException(
"SignedTxV1 tag/vsn mismatch: got " + tx.tag() + "/" + tx.vsn()
+ ", expected " + SignedTxV1.TAG + "/" + SignedTxV1.VSN);
}
List<RLP_Data> sigItems = new ArrayList<>();
if (tx.signatures() != null) {
for (byte[] sig : tx.signatures()) {
sigItems.add(item(nullToEmpty(sig)));
}
}
List<RLP_Data> items = new ArrayList<>(4);
items.add(item(BasicEncoders.encodeUint(tx.tag())));
items.add(item(BasicEncoders.encodeUint(tx.vsn())));
items.add(new RLP_List(sigItems));
items.add(item(nullToEmpty(tx.transaction())));
return new RLP_List(items);
}
// ------------------------------------------------------------------
// Decode
// ------------------------------------------------------------------
public static SpendTxV1 decodeSpendTxV1(byte[] wire) {
return fromRlpSpend(RLP.decode(wire));
}
public static SignedTxV1 decodeSignedTxV1(byte[] wire) {
return fromRlpSigned(RLP.decode(wire));
}
public static SpendTxV1 fromRlpSpend(RLP_Data data) {
List<RLP_Data> items = expectList(data, 10, "SpendTxV1");
int tag = decodeIntField(items.get(0), "tag");
int vsn = decodeIntField(items.get(1), "vsn");
if (tag != SpendTxV1.TAG || vsn != SpendTxV1.VSN) {
throw new IllegalArgumentException(
"not a SpendTxV1: tag=" + tag + " vsn=" + vsn);
}
Id sender = BasicEncoders.decodeId(expectItem(items.get(2), "senderId"));
Id recipient = BasicEncoders.decodeId(expectItem(items.get(3), "recipientId"));
BigInteger amount = BasicEncoders.decodeUint(expectItem(items.get(4), "amount"));
BigInteger gasPrice = BasicEncoders.decodeUint(expectItem(items.get(5), "gasPrice"));
BigInteger gas = BasicEncoders.decodeUint(expectItem(items.get(6), "gas"));
BigInteger ttl = BasicEncoders.decodeUint(expectItem(items.get(7), "ttl"));
BigInteger nonce = BasicEncoders.decodeUint(expectItem(items.get(8), "nonce"));
byte[] payload = expectItem(items.get(9), "payload");
return new SpendTxV1(
tag, vsn, sender, recipient,
amount, gasPrice, gas, ttl, nonce, payload);
}
public static SignedTxV1 fromRlpSigned(RLP_Data data) {
List<RLP_Data> items = expectList(data, 4, "SignedTxV1");
int tag = decodeIntField(items.get(0), "tag");
int vsn = decodeIntField(items.get(1), "vsn");
if (tag != SignedTxV1.TAG || vsn != SignedTxV1.VSN) {
throw new IllegalArgumentException(
"not a SignedTxV1: tag=" + tag + " vsn=" + vsn);
}
List<byte[]> signatures = new ArrayList<>();
for (RLP_Data el : expectList(items.get(2), -1, "signatures")) {
signatures.add(expectItem(el, "signature"));
}
byte[] transaction = expectItem(items.get(3), "transaction");
return new SignedTxV1(tag, vsn, signatures, transaction);
}
/**
* Peek tag/vsn from a top-level RLP list without fully decoding.
*
* @return {@code int[]{tag, vsn}}
*/
public static int[] peekTagVsn(byte[] wire) {
RLP_Data data = RLP.decode(wire);
List<RLP_Data> items = expectList(data, -1, "chain object");
if (items.size() < 2) {
throw new IllegalArgumentException("RLP list too short for tag/vsn");
}
return new int[] {
decodeIntField(items.get(0), "tag"),
decodeIntField(items.get(1), "vsn")
};
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private static RLP_Item item(byte[] bytes) {
return new RLP_Item(bytes);
}
private static byte[] nullToEmpty(byte[] b) {
return b != null ? b : new byte[0];
}
private static void require(Object o, String name) {
if (o == null) {
throw new IllegalArgumentException(name + " is null");
}
}
private static List<RLP_Data> expectList(RLP_Data data, int expectedSize, String what) {
if (!(data instanceof RLP_List list)) {
throw new IllegalArgumentException(what + ": expected RLP list");
}
List<RLP_Data> items = list.items;
if (expectedSize >= 0 && items.size() != expectedSize) {
throw new IllegalArgumentException(
what + ": expected " + expectedSize + " fields, got " + items.size());
}
return items;
}
private static byte[] expectItem(RLP_Data data, String field) {
if (!(data instanceof RLP_Item item)) {
throw new IllegalArgumentException(field + ": expected RLP item");
}
return item.bytes != null ? item.bytes : new byte[0];
}
private static int decodeIntField(RLP_Data data, String field) {
BigInteger n = BasicEncoders.decodeUint(expectItem(data, field));
if (n.bitLength() > 31) {
throw new IllegalArgumentException(field + " does not fit in int: " + n);
}
return n.intValue();
}
}
@@ -0,0 +1,130 @@
/*
* 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.
*
* SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial
*/
package swiss.qpq.gajumaru.core.serialization;
import swiss.qpq.gajumaru.core.asn1.Id;
import java.math.BigInteger;
import java.util.Arrays;
/**
* Primitive field encoders matching {@code gmserialization:encode_field/2}
* and {@code gmser_id:encode/1}.
*
* <p>These produce bare payloads (not RLP-framed). {@link Asn1Rlp} wraps them
* in RLP items / lists.
*/
public final class BasicEncoders {
private BasicEncoders() {}
/**
* Minimal big-endian unsigned encoding, matching
* {@code binary:encode_unsigned/1}. Zero is {@code <<0>>}.
*/
public static byte[] encodeUint(BigInteger n) {
if (n == null || n.signum() < 0) {
throw new IllegalArgumentException("expected non-negative integer");
}
if (n.signum() == 0) {
return new byte[] { 0 };
}
byte[] raw = n.toByteArray(); // may include a leading 0 sign byte
if (raw[0] == 0) {
return Arrays.copyOfRange(raw, 1, raw.length);
}
return raw;
}
public static byte[] encodeUint(long n) {
if (n < 0) {
throw new IllegalArgumentException("expected non-negative integer");
}
return encodeUint(BigInteger.valueOf(n));
}
public static byte[] encodeUint(int n) {
return encodeUint((long) n);
}
/**
* Inverse of {@link #encodeUint(BigInteger)}.
*/
public static BigInteger decodeUint(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
throw new IllegalArgumentException("empty integer encoding");
}
// Reject non-minimal encodings with a leading zero (except for 0 itself).
if (bytes.length > 1 && bytes[0] == 0) {
throw new IllegalArgumentException("non-minimal integer encoding");
}
return new BigInteger(1, bytes);
}
/**
* 33-byte id wire form: {@code <<Type:8, Value:32/binary>>}, matching
* {@code gmser_id:encode/1} for simple tags (and extended account when
* {@code type >= 0x80}).
*/
public static byte[] encodeId(Id id) {
if (id == null) {
throw new IllegalArgumentException("id is null");
}
byte[] value = id.value();
if (value == null || value.length != 32) {
throw new IllegalArgumentException(
"id value must be 32 bytes, got "
+ (value == null ? "null" : value.length));
}
int type = id.type();
if (type < 0 || type > 255) {
throw new IllegalArgumentException("id type out of byte range: " + type);
}
byte[] out = new byte[33];
out[0] = (byte) type;
System.arraycopy(value, 0, out, 1, 32);
return out;
}
public static Id decodeId(byte[] bytes) {
if (bytes == null || bytes.length != 33) {
throw new IllegalArgumentException(
"id encoding must be 33 bytes, got "
+ (bytes == null ? "null" : bytes.length));
}
int type = bytes[0] & 0xFF;
byte[] value = Arrays.copyOfRange(bytes, 1, 33);
return new Id(type, value);
}
public static byte[] encodeBool(boolean v) {
return new byte[] { (byte) (v ? 1 : 0) };
}
public static boolean decodeBool(byte[] bytes) {
if (bytes == null || bytes.length != 1) {
throw new IllegalArgumentException("bool encoding must be 1 byte");
}
return switch (bytes[0] & 0xFF) {
case 0 -> false;
case 1 -> true;
default -> throw new IllegalArgumentException(
"illegal bool encoding: " + (bytes[0] & 0xFF));
};
}
}
@@ -0,0 +1,221 @@
/*
* Copyright (c) 2026 QPQ AG <info@qpq.swiss>. All rights reserved.
* Project: Gajumaru Core Java Libraries <gajumaru.io>
*
* SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-QPQ-Commercial
*/
package swiss.qpq.gajumaru.core.serialization;
import swiss.qpq.gajumaru.core.asn1.Id;
import swiss.qpq.gajumaru.core.asn1.SignedTxV1;
import swiss.qpq.gajumaru.core.asn1.SpendTxV1;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
/**
* Golden-vector equivalence tests against {@code gmser_chain_objects:serialize/4}
* (Erlang). Run via {@code bin/test-asn1-rlp}.
*
* <p>Vectors generated with:
* <pre>
* Sender = gmser_id:create(account, &lt;&lt;1:256&gt;&gt;),
* Recip = gmser_id:create(account, &lt;&lt;2:256&gt;&gt;),
* gmser_chain_objects:serialize(spend_tx, 1, Template, Fields)
* </pre>
*/
public final class Asn1RlpEquivalenceTest {
private static final HexFormat HEX = HexFormat.of().withUpperCase();
// <<1:256>> and <<2:256>> as 32-byte big-endian
private static final byte[] PUB1 = hex(
"0000000000000000000000000000000000000000000000000000000000000001");
private static final byte[] PUB2 = hex(
"0000000000000000000000000000000000000000000000000000000000000002");
/** account tag = 1 */
private static final Id SENDER = new Id(1, PUB1);
private static final Id RECIPIENT = new Id(1, PUB2);
private static final String SPEND_HEX =
"F8500C01A1010000000000000000000000000000000000000000000000000000000000000001"
+ "A1010000000000000000000000000000000000000000000000000000000000000002"
+ "6401824E200001826869";
private static final String SPEND0_HEX =
"F84C0C01A1010000000000000000000000000000000000000000000000000000000000000001"
+ "A1010000000000000000000000000000000000000000000000000000000000000002"
+ "000000000080";
private static final String SIGNED_HEX =
"F8610B01CA84736967418473696742B852"
+ "F8500C01A1010000000000000000000000000000000000000000000000000000000000000001"
+ "A1010000000000000000000000000000000000000000000000000000000000000002"
+ "6401824E200001826869";
private static final String SENDER_ID_HEX =
"010000000000000000000000000000000000000000000000000000000000000001";
private int failures = 0;
public static void main(String[] args) {
Asn1RlpEquivalenceTest t = new Asn1RlpEquivalenceTest();
t.testEncodeId();
t.testSpendMatchesErlang();
t.testSpendZeroEmptyMatchesErlang();
t.testSpendRoundTrip();
t.testSignedMatchesErlang();
t.testSignedRoundTrip();
t.testPeekTagVsn();
if (t.failures > 0) {
System.err.println(t.failures + " failure(s)");
System.exit(1);
}
System.out.println("All Asn1Rlp equivalence tests passed.");
}
void testEncodeId() {
byte[] enc = BasicEncoders.encodeId(SENDER);
assertHex("encodeId(account, <<1:256>>)", SENDER_ID_HEX, enc);
Id back = BasicEncoders.decodeId(enc);
assertEq("id type", 1, back.type());
assertBytes("id value", PUB1, back.value());
}
void testSpendMatchesErlang() {
SpendTxV1 tx = sampleSpend();
byte[] wire = Asn1Rlp.encode(tx);
assertHex("SpendTxV1 encode vs Erlang", SPEND_HEX, wire);
}
void testSpendZeroEmptyMatchesErlang() {
SpendTxV1 tx = new SpendTxV1(
SpendTxV1.TAG, SpendTxV1.VSN,
SENDER, RECIPIENT,
BigInteger.ZERO, BigInteger.ZERO, BigInteger.ZERO,
BigInteger.ZERO, BigInteger.ZERO,
new byte[0]);
byte[] wire = Asn1Rlp.encode(tx);
assertHex("SpendTxV1 zero/empty vs Erlang", SPEND0_HEX, wire);
}
void testSpendRoundTrip() {
SpendTxV1 tx = sampleSpend();
SpendTxV1 back = Asn1Rlp.decodeSpendTxV1(Asn1Rlp.encode(tx));
assertEq("roundtrip tag", tx.tag(), back.tag());
assertEq("roundtrip vsn", tx.vsn(), back.vsn());
assertEq("roundtrip sender type", tx.senderId().type(), back.senderId().type());
assertBytes("roundtrip sender val", tx.senderId().value(), back.senderId().value());
assertEq("roundtrip amount", tx.amount(), back.amount());
assertEq("roundtrip gasPrice", tx.gasPrice(), back.gasPrice());
assertEq("roundtrip gas", tx.gas(), back.gas());
assertEq("roundtrip ttl", tx.ttl(), back.ttl());
assertEq("roundtrip nonce", tx.nonce(), back.nonce());
assertBytes("roundtrip payload", tx.payload(), back.payload());
}
void testSignedMatchesErlang() {
byte[] inner = Asn1Rlp.encode(sampleSpend());
SignedTxV1 signed = new SignedTxV1(
SignedTxV1.TAG, SignedTxV1.VSN,
List.of(bytes("sigA"), bytes("sigB")),
inner);
byte[] wire = Asn1Rlp.encode(signed);
assertHex("SignedTxV1 encode vs Erlang", SIGNED_HEX, wire);
}
void testSignedRoundTrip() {
byte[] inner = Asn1Rlp.encode(sampleSpend());
SignedTxV1 signed = new SignedTxV1(
SignedTxV1.TAG, SignedTxV1.VSN,
List.of(bytes("sigA"), bytes("sigB")),
inner);
SignedTxV1 back = Asn1Rlp.decodeSignedTxV1(Asn1Rlp.encode(signed));
assertEq("signed tag", signed.tag(), back.tag());
assertEq("signed sigs size", signed.signatures().size(), back.signatures().size());
assertBytes("signed sig0", signed.signatures().get(0), back.signatures().get(0));
assertBytes("signed sig1", signed.signatures().get(1), back.signatures().get(1));
assertBytes("signed tx", signed.transaction(), back.transaction());
// nested spend still decodes
SpendTxV1 spend = Asn1Rlp.decodeSpendTxV1(back.transaction());
assertEq("nested amount", sampleSpend().amount(), spend.amount());
}
void testPeekTagVsn() {
int[] tv = Asn1Rlp.peekTagVsn(hex(SPEND_HEX));
assertEq("peek tag", SpendTxV1.TAG, tv[0]);
assertEq("peek vsn", SpendTxV1.VSN, tv[1]);
int[] tv2 = Asn1Rlp.peekTagVsn(hex(SIGNED_HEX));
assertEq("peek signed tag", SignedTxV1.TAG, tv2[0]);
assertEq("peek signed vsn", SignedTxV1.VSN, tv2[1]);
}
private static SpendTxV1 sampleSpend() {
return new SpendTxV1(
SpendTxV1.TAG, SpendTxV1.VSN,
SENDER, RECIPIENT,
BigInteger.valueOf(100),
BigInteger.valueOf(1),
BigInteger.valueOf(20_000),
BigInteger.ZERO,
BigInteger.ONE,
bytes("hi"));
}
// --- tiny assert helpers ---
private void assertHex(String label, String expectedHex, byte[] actual) {
String got = HEX.formatHex(actual);
if (!expectedHex.equalsIgnoreCase(got)) {
fail(label + "\n expected: " + expectedHex + "\n actual: " + got);
} else {
ok(label);
}
}
private void assertBytes(String label, byte[] expected, byte[] actual) {
if (!Arrays.equals(expected, actual)) {
fail(label + "\n expected: " + HEX.formatHex(expected)
+ "\n actual: " + HEX.formatHex(actual));
} else {
ok(label);
}
}
private void assertEq(String label, Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
fail(label + ": expected " + expected + ", got " + actual);
} else {
ok(label);
}
}
private void assertEq(String label, int expected, int actual) {
if (expected != actual) {
fail(label + ": expected " + expected + ", got " + actual);
} else {
ok(label);
}
}
private void ok(String label) {
System.out.println(" ok " + label);
}
private void fail(String msg) {
failures++;
System.err.println("FAIL " + msg);
}
private static byte[] hex(String h) {
return HexFormat.of().parseHex(h);
}
private static byte[] bytes(String s) {
return s.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
}