diff --git a/gajumaru-core/bin/asn1_to_java b/gajumaru-core/bin/asn1_to_java new file mode 100755 index 0000000..37678c2 --- /dev/null +++ b/gajumaru-core/bin/asn1_to_java @@ -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 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/ 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:])) diff --git a/gajumaru-core/bin/compile b/gajumaru-core/bin/compile index 833bf63..41870a3 100755 --- a/gajumaru-core/bin/compile +++ b/gajumaru-core/bin/compile @@ -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 diff --git a/gajumaru-core/bin/test-asn1-rlp b/gajumaru-core/bin/test-asn1-rlp new file mode 100755 index 0000000..2a84e9e --- /dev/null +++ b/gajumaru-core/bin/test-asn1-rlp @@ -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 diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/Id.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/Id.java new file mode 100644 index 0000000..718407a --- /dev/null +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/Id.java @@ -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 +) { +} diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/SignedTxV1.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/SignedTxV1.java new file mode 100644 index 0000000..f8b2f00 --- /dev/null +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/SignedTxV1.java @@ -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 signatures, + byte[] transaction +) { + + public static final int TAG = 11; + public static final int VSN = 1; +} diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/SpendTxV1.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/SpendTxV1.java new file mode 100644 index 0000000..b8f57cc --- /dev/null +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/asn1/SpendTxV1.java @@ -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; +} diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/serialization/Asn1Rlp.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/serialization/Asn1Rlp.java new file mode 100644 index 0000000..d065dd6 --- /dev/null +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/serialization/Asn1Rlp.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * This program is dual-licensed: + * 1) Under the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version (AGPL-3.0-or-later). + * + * 2) Under a commercial/proprietary license available directly from QPQ AG. + * If you wish to use this software outside the strict constraints of the + * AGPLv3 (e.g. within a closed-source or proprietary product), you must + * purchase a commercial license from QPQ AG. + * + * 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. + * + *

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. + * + *

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). + * + *

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 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 sigItems = new ArrayList<>(); + if (tx.signatures() != null) { + for (byte[] sig : tx.signatures()) { + sigItems.add(item(nullToEmpty(sig))); + } + } + List 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 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 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 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 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 expectList(RLP_Data data, int expectedSize, String what) { + if (!(data instanceof RLP_List list)) { + throw new IllegalArgumentException(what + ": expected RLP list"); + } + List 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(); + } +} diff --git a/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/serialization/BasicEncoders.java b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/serialization/BasicEncoders.java new file mode 100644 index 0000000..37d4c4b --- /dev/null +++ b/gajumaru-core/src/main/java/swiss/qpq/gajumaru/core/serialization/BasicEncoders.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * This program is dual-licensed: + * 1) Under the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version (AGPL-3.0-or-later). + * + * 2) Under a commercial/proprietary license available directly from QPQ AG. + * If you wish to use this software outside the strict constraints of the + * AGPLv3 (e.g. within a closed-source or proprietary product), you must + * purchase a commercial license from QPQ AG. + * + * 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}. + * + *

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 <>}, 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)); + }; + } +} diff --git a/gajumaru-core/src/test/java/swiss/qpq/gajumaru/core/serialization/Asn1RlpEquivalenceTest.java b/gajumaru-core/src/test/java/swiss/qpq/gajumaru/core/serialization/Asn1RlpEquivalenceTest.java new file mode 100644 index 0000000..ed28367 --- /dev/null +++ b/gajumaru-core/src/test/java/swiss/qpq/gajumaru/core/serialization/Asn1RlpEquivalenceTest.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 QPQ AG . All rights reserved. + * Project: Gajumaru Core Java Libraries + * + * 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}. + * + *

Vectors generated with: + *

+ *   Sender = gmser_id:create(account, <<1:256>>),
+ *   Recip  = gmser_id:create(account, <<2:256>>),
+ *   gmser_chain_objects:serialize(spend_tx, 1, Template, Fields)
+ * 
+ */ +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); + } +}