Compare commits

1 Commits

Author SHA1 Message Date
Ulf Wiger 53eaa3056a 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.
2026-07-22 11:31:15 +02:00
9 changed files with 1144 additions and 2 deletions
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""
Generate Java record declarations from GajumaruChainObjects.asn
(or any module produced by gmser_schema_export).
This is intentionally a thin, schema-shaped codegen: it does NOT emit a
BER/DER codec. The abstract types are for typed construction; RLP remains
the wire format (see swiss.qpq.gajumaru.core.encoding.RLP).
Usage (from gajumaru-core/):
bin/asn1_to_java \\
--asn ../../gajumaru/asn1_generated/GajumaruChainObjects.asn \\
--types Id,SignedTxV1,SpendTxV1
bin/asn1_to_java --asn ... --all
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional, Tuple, Union
# ---------------------------------------------------------------------------
# ASN.1 model (minimal)
# ---------------------------------------------------------------------------
AsnType = Union[
"PrimitiveType",
"NamedType",
"SequenceType",
"SequenceOfType",
]
@dataclass
class PrimitiveType:
kind: str # integer, octet_string, boolean, fixed_integer
constraint: Optional[str] = None # e.g. "0..255" or "12"
@dataclass
class NamedType:
name: str # Id, BigInt, SpendTxV1, ...
@dataclass
class SequenceField:
name: str
type: AsnType
@dataclass
class SequenceType:
fields: List[SequenceField]
@dataclass
class SequenceOfType:
elem: AsnType
@dataclass
class TypeDef:
name: str
type: AsnType
comment: str = ""
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
COMMENT_RE = re.compile(r"--[^\n]*")
TYPE_DEF_RE = re.compile(
r"([A-Z][A-Za-z0-9]*)\s*::=\s*",
)
def strip_comments(text: str) -> str:
return COMMENT_RE.sub("", text)
def parse_module(text: str) -> dict[str, TypeDef]:
text = strip_comments(text)
# Drop module wrapper noise; keep type assignments only.
begin = text.find("BEGIN")
end = text.rfind("END")
if begin >= 0 and end > begin:
text = text[begin + 5 : end]
defs: dict[str, TypeDef] = {}
pos = 0
while True:
m = TYPE_DEF_RE.search(text, pos)
if not m:
break
name = m.group(1)
start = m.end()
# Find next top-level type def or EOF
nxt = TYPE_DEF_RE.search(text, start)
body = text[start : nxt.start() if nxt else len(text)].strip()
# Trim trailing junk
body = body.rstrip().rstrip(";").strip()
try:
asn_type = parse_type(body)
except Exception as e:
raise SystemExit(f"Failed to parse type {name}: {e}\nBody:\n{body[:200]}") from e
defs[name] = TypeDef(name=name, type=asn_type)
pos = nxt.start() if nxt else len(text)
return defs
def parse_type(s: str) -> AsnType:
s = s.strip()
if s.startswith("SEQUENCE OF"):
rest = s[len("SEQUENCE OF") :].strip()
return SequenceOfType(parse_type(rest))
if s.startswith("SEQUENCE"):
rest = s[len("SEQUENCE") :].strip()
if not rest.startswith("{"):
raise ValueError(f"expected SEQUENCE {{...}}, got: {s[:60]}")
inner = extract_braces(rest)
return SequenceType(parse_fields(inner))
if s.startswith("INTEGER"):
rest = s[len("INTEGER") :].strip()
if rest.startswith("(") and rest.endswith(")"):
c = rest[1:-1].strip()
if ".." in c or c == "MAX" or c.endswith("MAX"):
return PrimitiveType("integer", c)
# single value constraint: fixed integer
if re.fullmatch(r"\d+", c):
return PrimitiveType("fixed_integer", c)
return PrimitiveType("integer", c)
return PrimitiveType("integer")
if s.startswith("OCTET STRING"):
rest = s[len("OCTET STRING") :].strip()
c = None
if rest.startswith("(") and ")" in rest:
c = rest[rest.find("(") + 1 : rest.rfind(")")]
return PrimitiveType("octet_string", c)
if s == "BOOLEAN":
return PrimitiveType("boolean")
if re.fullmatch(r"[A-Z][A-Za-z0-9]*", s):
return NamedType(s)
raise ValueError(f"unsupported type expression: {s[:80]!r}")
def extract_braces(s: str) -> str:
"""s starts with '{'; return content inside matching braces."""
assert s[0] == "{"
depth = 0
for i, ch in enumerate(s):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return s[1:i]
raise ValueError("unbalanced braces")
def parse_fields(body: str) -> List[SequenceField]:
"""Parse 'name Type, name Type' with nested braces."""
fields: List[SequenceField] = []
parts = split_top_level(body, ",")
for part in parts:
part = part.strip()
if not part:
continue
# field name is first token (lowercase start in our export)
m = re.match(r"([A-Za-z][A-Za-z0-9]*)\s+(.+)$", part, re.DOTALL)
if not m:
raise ValueError(f"bad field: {part[:60]!r}")
fname, ftype = m.group(1), m.group(2).strip()
fields.append(SequenceField(fname, parse_type(ftype)))
return fields
def split_top_level(s: str, sep: str) -> List[str]:
parts: List[str] = []
depth = 0
start = 0
for i, ch in enumerate(s):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
elif ch == sep and depth == 0:
parts.append(s[start:i])
start = i + 1
parts.append(s[start:])
return parts
# ---------------------------------------------------------------------------
# Java emission
# ---------------------------------------------------------------------------
HEADER = """\
/*
* 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.
*/
"""
def java_type_name(name: str) -> str:
return name
def field_to_java(
f: SequenceField,
owner: str,
nested: List[Tuple[str, SequenceType]],
) -> Tuple[str, str]:
"""Return (javaType, fieldName). May append nested type defs."""
jtype = asn_to_java(f.type, owner, f.name, nested)
return jtype, f.name
def asn_to_java(
t: AsnType,
owner: str,
field_name: str,
nested: List[Tuple[str, SequenceType]],
) -> str:
if isinstance(t, NamedType):
# Built-in aliases
if t.name == "BigInt":
return "java.math.BigInteger"
if t.name in ("Uint8", "Uint16"):
return "int"
if t.name == "Uint32":
return "long"
if t.name in ("Uint64", "Uint128"):
return "java.math.BigInteger"
return t.name
if isinstance(t, PrimitiveType):
if t.kind == "boolean":
return "boolean"
if t.kind == "octet_string":
return "byte[]"
if t.kind == "fixed_integer":
return "int"
if t.kind == "integer":
# constrained small ranges map to int/long when obvious
if t.constraint in ("0..255", "0..65535"):
return "int"
if t.constraint == "0..4294967295":
return "long"
return "java.math.BigInteger"
raise ValueError(t)
if isinstance(t, SequenceOfType):
elem = asn_to_java(t.elem, owner, field_name + "Elem", nested)
return f"java.util.List<{box(elem)}>"
if isinstance(t, SequenceType):
nested_name = f"{owner}_{capitalize(field_name)}"
nested.append((nested_name, t))
# also need to resolve nested field types recursively for emission
return nested_name
raise ValueError(f"unknown type {t}")
def box(jtype: str) -> str:
return {
"int": "Integer",
"long": "Long",
"boolean": "Boolean",
"byte[]": "byte[]", # List<byte[]> is awkward but ok for now
}.get(jtype, jtype)
def capitalize(s: str) -> str:
return s[:1].upper() + s[1:] if s else s
def emit_sequence_record(
name: str,
seq: SequenceType,
package: str,
all_nested: List[Tuple[str, SequenceType]],
) -> str:
nested: List[Tuple[str, SequenceType]] = []
components = []
constants = []
for f in seq.fields:
jtype, jname = field_to_java(f, name, nested)
components.append(f" {jtype} {jname}")
if isinstance(f.type, PrimitiveType) and f.type.kind == "fixed_integer":
if f.name == "tag":
constants.append(f" public static final int TAG = {f.type.constraint};")
elif f.name == "vsn":
constants.append(f" public static final int VSN = {f.type.constraint};")
all_nested.extend(nested)
body = ",\n".join(components)
const_block = ("\n" + "\n".join(constants) + "\n") if constants else ""
return (
f"package {package};\n\n"
f"{HEADER}"
f"public record {name}(\n{body}\n) {{\n"
f"{const_block}"
f"}}\n"
)
def emit_alias(name: str, t: AsnType, package: str) -> Optional[str]:
"""Emit a tiny holder for INTEGER aliases we don't map away."""
# We map BigInt/Uint* at use sites; still emit Id as a record.
if name in ("BigInt", "Uint8", "Uint16", "Uint32", "Uint64", "Uint128"):
return None
if isinstance(t, SequenceType):
return None # handled elsewhere
if isinstance(t, PrimitiveType) and t.kind == "integer":
# skip pure aliases
return None
return None
def collect_dependencies(name: str, defs: dict[str, TypeDef], acc: set[str]) -> None:
if name in acc or name not in defs:
return
acc.add(name)
t = defs[name].type
walk_deps(t, defs, acc)
def walk_deps(t: AsnType, defs: dict[str, TypeDef], acc: set[str]) -> None:
if isinstance(t, NamedType):
if t.name in defs:
collect_dependencies(t.name, defs, acc)
elif isinstance(t, SequenceOfType):
walk_deps(t.elem, defs, acc)
elif isinstance(t, SequenceType):
for f in t.fields:
walk_deps(f.type, defs, acc)
def generate(
defs: dict[str, TypeDef],
wanted: List[str],
package: str,
out_dir: Path,
) -> List[Path]:
# Always include named deps of wanted types
selected: set[str] = set()
for w in wanted:
if w not in defs:
raise SystemExit(f"Unknown type {w}. Available: {', '.join(sorted(defs))}")
collect_dependencies(w, defs, selected)
out_dir.mkdir(parents=True, exist_ok=True)
written: List[Path] = []
# Emit in dependency-friendly order: common first
order = sorted(selected, key=lambda n: (0 if n == "Id" else 1, n))
for name in order:
tdef = defs[name]
t = tdef.type
if isinstance(t, SequenceType):
nested_acc: List[Tuple[str, SequenceType]] = []
src = emit_sequence_record(name, t, package, nested_acc)
path = out_dir / f"{name}.java"
path.write_text(src)
written.append(path)
# nested anonymous sequences as separate top-level records
for nname, nseq in nested_acc:
more: List[Tuple[str, SequenceType]] = []
nsrc = emit_sequence_record(nname, nseq, package, more)
npath = out_dir / f"{nname}.java"
npath.write_text(nsrc)
written.append(npath)
# flatten one level of nesting iteratively
queue = list(more)
while queue:
qn, qs = queue.pop(0)
more2: List[Tuple[str, SequenceType]] = []
qsrc = emit_sequence_record(qn, qs, package, more2)
qpath = out_dir / f"{qn}.java"
qpath.write_text(qsrc)
written.append(qpath)
queue.extend(more2)
else:
alias = emit_alias(name, t, package)
if alias:
path = out_dir / f"{name}.java"
path.write_text(alias)
written.append(path)
return written
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--asn",
type=Path,
required=True,
help="Path to GajumaruChainObjects.asn",
)
ap.add_argument(
"--package",
default="swiss.qpq.gajumaru.core.asn1",
help="Java package for generated sources",
)
ap.add_argument(
"--out",
type=Path,
default=None,
help="Output directory for .java files "
"(default: src/main/java/<package path> under gajumaru-core)",
)
ap.add_argument(
"--types",
default="Id,SignedTxV1,SpendTxV1",
help="Comma-separated type names to generate (plus dependencies)",
)
ap.add_argument(
"--all",
action="store_true",
help="Generate all SEQUENCE types in the module",
)
args = ap.parse_args(argv)
asn_path = args.asn.resolve()
if not asn_path.is_file():
print(f"ASN.1 file not found: {asn_path}", file=sys.stderr)
return 1
text = asn_path.read_text()
defs = parse_module(text)
if not defs:
print("No type definitions parsed", file=sys.stderr)
return 1
if args.all:
wanted = [n for n, d in defs.items() if isinstance(d.type, SequenceType)]
else:
wanted = [t.strip() for t in args.types.split(",") if t.strip()]
if args.out is None:
script_dir = Path(__file__).resolve().parent
project_dir = script_dir.parent
pkg_path = args.package.replace(".", "/")
out_dir = project_dir / "src" / "main" / "java" / pkg_path
else:
out_dir = args.out
written = generate(defs, wanted, args.package, out_dir)
print(f"Parsed {len(defs)} ASN.1 types from {asn_path}")
print(f"Wrote {len(written)} Java file(s) to {out_dir}:")
for p in written:
print(f" {p.name}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+10 -2
View File
@@ -8,14 +8,22 @@ set -e
abs_dir="$(cd -P $(dirname ${BASH_SOURCE}) && pwd)"
project_dir="$(dirname $abs_dir)"
# Prefer Homebrew OpenJDK when /usr/bin/java is a macOS stub.
if [[ -x /opt/homebrew/opt/openjdk/bin/javac ]]; then
export PATH="/opt/homebrew/opt/openjdk/bin:$PATH"
elif [[ -x /opt/homebrew/opt/openjdk@25/bin/javac ]]; then
export PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH"
elif [[ -x /opt/homebrew/opt/openjdk@21/bin/javac ]]; then
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
fi
# Clean
rm -rf "$project_dir/build/classes/*"
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
+39
View File
@@ -0,0 +1,39 @@
#! /usr/bin/env bash
# Compile main + Asn1Rlp equivalence tests and run them.
# Prefers Homebrew OpenJDK when /usr/bin/java is a macOS stub.
set -euo pipefail
abs_dir="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
project_dir="$(dirname "$abs_dir")"
if [[ -x /opt/homebrew/opt/openjdk/bin/javac ]]; then
export PATH="/opt/homebrew/opt/openjdk/bin:$PATH"
elif [[ -x /opt/homebrew/opt/openjdk@25/bin/javac ]]; then
export PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH"
elif [[ -x /opt/homebrew/opt/openjdk@21/bin/javac ]]; then
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
fi
if ! command -v javac >/dev/null || ! javac -version >/dev/null 2>&1; then
echo "No working JDK found (javac)." >&2
exit 1
fi
classes="$project_dir/build/classes"
test_classes="$project_dir/build/test-classes"
mkdir -p "$classes" "$test_classes"
echo "Using $(javac -version 2>&1)"
# Portable source collection (bash 3.2 has no mapfile)
main_sources=$(find "$project_dir/src/main/java" -name '*.java' | sort)
# shellcheck disable=SC2086
javac --release 21 -d "$classes" $main_sources
test_sources=$(find "$project_dir/src/test/java" -name '*Asn1Rlp*.java' | sort)
# shellcheck disable=SC2086
javac --release 21 -cp "$classes" -d "$test_classes" $test_sources
echo "Running Asn1RlpEquivalenceTest..."
java -cp "$classes:$test_classes" swiss.qpq.gajumaru.core.serialization.Asn1RlpEquivalenceTest
@@ -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);
}
}