Proof-of-concept: Add ASN.1-to-Java codegen and RLP codec for spend/signed tx subset. #1
Executable
+470
@@ -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:]))
|
||||||
@@ -8,14 +8,22 @@ set -e
|
|||||||
abs_dir="$(cd -P $(dirname ${BASH_SOURCE}) && pwd)"
|
abs_dir="$(cd -P $(dirname ${BASH_SOURCE}) && pwd)"
|
||||||
project_dir="$(dirname $abs_dir)"
|
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
|
# Clean
|
||||||
rm -rf "$project_dir/build/classes/*"
|
rm -rf "$project_dir/build/classes/*"
|
||||||
rm -f "$project_dir/Testinator.class"
|
rm -f "$project_dir/Testinator.class"
|
||||||
|
|
||||||
# Build every .java file
|
# Build every .java file
|
||||||
find "$project_dir/src/main/java" -name "*.java" | xargs javac \
|
find "$project_dir/src/main/java" -name "*.java" | xargs javac \
|
||||||
-source 21 \
|
--release 21 \
|
||||||
-target 21 \
|
|
||||||
-d "$project_dir/build/classes"
|
-d "$project_dir/build/classes"
|
||||||
|
|
||||||
# Build the Testinator thingy
|
# Build the Testinator thingy
|
||||||
|
|||||||
Executable
+39
@@ -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));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+221
@@ -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, <<1:256>>),
|
||||||
|
* Recip = gmser_id:create(account, <<2:256>>),
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user