Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53eaa3056a |
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)"
|
||||
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
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package swiss.qpq.gajumaru.core.formatting;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class GajuFormat {
|
||||
public enum Type { US, JP, METRIC, LEGACY }
|
||||
public enum Unit { GAJU, PUCK }
|
||||
public record FormatSpec(Type type, Unit unit, char separator, int span) {}
|
||||
|
||||
private GajuFormat() {}
|
||||
|
||||
private static final String GAJU_MARK = "木";
|
||||
private static final String PUCK_MARK = "本";
|
||||
private static final int FRACTION_LEN = 18;
|
||||
|
||||
private static final String[] JP_RANKS = {"", "万", "億", "兆", "京", "垓", "秭", "穣", "溝", "澗", "正", "載", "極"};
|
||||
private static final String[] METRIC_RANKS = {"", "k", "m", "G", "T", "P", "E", "Z", "Y", "r", "Q"};
|
||||
private static final String[] LEGACY_RANKS = {"", "k", "m", "b", "T", "q", "E", "Z", "Y", "r", "Q"};
|
||||
|
||||
public static String amount(FormatSpec spec, byte[] puckBytes) {
|
||||
String base10 = GmMonetaryFormatter.bytesToBase10PuckString(puckBytes);
|
||||
boolean isNegative = puckBytes.length > 0 && (puckBytes[0] & 0x80) != 0; // Handle sign tracking if applicable
|
||||
|
||||
return switch (spec.type()) {
|
||||
case US -> formatWestern(spec, base10);
|
||||
case JP -> formatMyriad(spec, base10, JP_RANKS, "-");
|
||||
case METRIC -> formatBestern(spec, base10, METRIC_RANKS);
|
||||
case LEGACY -> formatBestern(spec, base10, LEGACY_RANKS);
|
||||
};
|
||||
}
|
||||
|
||||
private static String formatWestern(FormatSpec spec, String base10) {
|
||||
if (spec.unit() == Unit.PUCK) {
|
||||
return PUCK_MARK + chunkString(base10, spec.separator(), spec.span(), false);
|
||||
}
|
||||
|
||||
// Split at the 18-digit Gaju <-> Puck boundary
|
||||
String[] split = splitPucksAndGajus(base10);
|
||||
String gajuStr = chunkString(split[0], spec.separator(), spec.span(), false);
|
||||
String puckStr = cleanTrailingZeros(split[1]);
|
||||
|
||||
if (puckStr.isEmpty()) {
|
||||
return GAJU_MARK + gajuStr;
|
||||
}
|
||||
return GAJU_MARK + gajuStr + "." + chunkString(puckStr, spec.separator(), spec.span(), true);
|
||||
}
|
||||
|
||||
private static String formatMyriad(FormatSpec spec, String base10, String[] ranks, String negSign) {
|
||||
if (spec.unit() == Unit.PUCK) {
|
||||
return processRanks(base10, ranks, 4, PUCK_MARK);
|
||||
}
|
||||
String[] split = splitPucksAndGajus(base10);
|
||||
String gajuFormatted = processRanks(split[0], ranks, 4, GAJU_MARK);
|
||||
String puckClean = cleanTrailingZeros(split[1]);
|
||||
|
||||
if (puckClean.isEmpty()) return gajuFormatted;
|
||||
return gajuFormatted + " " + processRanks(puckClean, ranks, 4, PUCK_MARK);
|
||||
}
|
||||
|
||||
private static String formatBestern(FormatSpec spec, String base10, String[] ranks) {
|
||||
if (spec.unit() == Unit.PUCK) {
|
||||
return PUCK_MARK + processRanks(base10, ranks, 3, "P");
|
||||
}
|
||||
String[] split = splitPucksAndGajus(base10);
|
||||
String gajuPart = GAJU_MARK + processRanks(split[0], ranks, 3, "G");
|
||||
String puckClean = cleanTrailingZeros(split[1]);
|
||||
|
||||
if (puckClean.isEmpty()) return gajuPart;
|
||||
return gajuPart + " " + processRanks(puckClean, ranks, 3, "P");
|
||||
}
|
||||
|
||||
private static String[] splitPucksAndGajus(String base10) {
|
||||
if (base10.length() <= FRACTION_LEN) {
|
||||
char[] zeros = new char[FRACTION_LEN - base10.length()];
|
||||
Arrays.fill(zeros, '0');
|
||||
return new String[]{"0", new String(zeros) + base10};
|
||||
}
|
||||
int cut = base10.length() - FRACTION_LEN;
|
||||
return new String[]{base10.substring(0, cut), base10.substring(cut)};
|
||||
}
|
||||
|
||||
private static String cleanTrailingZeros(String str) {
|
||||
int idx = str.length() - 1;
|
||||
while (idx >= 0 && str.charAt(idx) == '0') idx--;
|
||||
return idx < 0 ? "" : str.substring(0, idx + 1);
|
||||
}
|
||||
|
||||
private static String chunkString(String str, char sep, int span, boolean isFraction) {
|
||||
if (str.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int len = str.length();
|
||||
for (int i = 0; i < len; i++) {
|
||||
if (i > 0 && (isFraction ? i % span == 0 : (len - i) % span == 0)) {
|
||||
sb.append(sep);
|
||||
}
|
||||
sb.append(str.charAt(i));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String processRanks(String numericStr, String[] ranks, int span, String endingSymbol) {
|
||||
if (numericStr.equals("0") || numericStr.isEmpty()) return "0 " + endingSymbol;
|
||||
StringBuilder result = new StringBuilder();
|
||||
int len = numericStr.length();
|
||||
int rankIndex = 0;
|
||||
|
||||
for (int i = len; i > 0; i -= span) {
|
||||
int start = Math.max(0, i - span);
|
||||
String chunk = numericStr.substring(start, i);
|
||||
long val = Long.parseLong(chunk);
|
||||
if (val > 0) {
|
||||
result.insert(0, val + ranks[rankIndex] + " ");
|
||||
}
|
||||
rankIndex++;
|
||||
}
|
||||
return result.toString().trim() + endingSymbol;
|
||||
}
|
||||
|
||||
private static final long ONE_GAJU_FACTOR = 1_000_000_000_000_000_000L;
|
||||
|
||||
private static final Map<Character, Long> MULTIPLIERS = new HashMap<>();
|
||||
static {
|
||||
// Warhammer 40k heresy meets Base 10k primacy
|
||||
MULTIPLIERS.put('万', 10_000L);
|
||||
MULTIPLIERS.put('億', 100_000_000L);
|
||||
MULTIPLIERS.put('兆', 1_000_000_000_000L);
|
||||
MULTIPLIERS.put('京', 10_000_000_000_000_000L);
|
||||
// NOTE: Higher ranges (垓, 秭) require BigInteger parsing if values exceed Long.MAX_VALUE.
|
||||
// For standard UI entries, Long tracking handles up to 9 Quintillion Pucks
|
||||
|
||||
// SI / Heresy notations mapping rules
|
||||
MULTIPLIERS.put('k', 1_000L);
|
||||
MULTIPLIERS.put('m', 1_000_000L);
|
||||
MULTIPLIERS.put('G', 1_000_000_000L);
|
||||
MULTIPLIERS.put('g', 1_000_000_000L);
|
||||
MULTIPLIERS.put('b', 1_000_000_000L); // Hertical billion flag
|
||||
MULTIPLIERS.put('T', 1_000_000_000_000L);
|
||||
MULTIPLIERS.put('t', 1_000_000_000_000L);
|
||||
MULTIPLIERS.put('P', 1_000_000_000_000_000L);
|
||||
MULTIPLIERS.put('p', 1_000_000_000_000_000L);
|
||||
MULTIPLIERS.put('q', 1_000_000_000_000_000L); // Heretical quadrillion flag
|
||||
}
|
||||
|
||||
public static byte[] read(String rawInput) {
|
||||
if (rawInput == null || rawInput.isEmpty()) throw new IllegalArgumentException("Empty string context");
|
||||
|
||||
boolean isNegative = false;
|
||||
String input = rawInput.trim();
|
||||
if (input.startsWith("-") || input.startsWith("-") || input.startsWith("-")) {
|
||||
isNegative = true;
|
||||
input = input.substring(1).trim();
|
||||
}
|
||||
|
||||
boolean forceGaju = input.startsWith("木");
|
||||
boolean forcePuck = input.startsWith("本");
|
||||
if (forceGaju || forcePuck) {
|
||||
input = input.substring(1).trim();
|
||||
}
|
||||
|
||||
input = input.replace(",", "").replace("_", "").replace(" ", "");
|
||||
|
||||
if (input.contains(".")) {
|
||||
int dotIdx = input.indexOf('.');
|
||||
String gajuString = input.substring(0, dotIdx);
|
||||
String puckString = input.substring(dotIdx + 1);
|
||||
|
||||
StringBuilder sb = new StringBuilder(puckString);
|
||||
while (sb.length() < 18) sb.append('0');
|
||||
if (sb.length() > 18) throw new IllegalArgumentException("Precision overflow error");
|
||||
|
||||
byte[] gajuBytes = GmMonetaryParser.parseBase10ToBytes(standardizeDigits(gajuString));
|
||||
byte[] puckBytes = GmMonetaryParser.parseBase10ToBytes(standardizeDigits(sb.toString()));
|
||||
|
||||
return addAtomicPuckArrays(multiplyByGajuFactor(gajuBytes), puckBytes, isNegative);
|
||||
}
|
||||
|
||||
return parseRankedString(input, forcePuck, isNegative);
|
||||
}
|
||||
|
||||
private static String standardizeDigits(String raw) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
if (c >= '0' && c <= '9') {
|
||||
sb.append((char) (c - '0' + '0')); // full-width -> half-width
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static byte[] parseRankedString(String input, boolean forcePuck, boolean isNegative) {
|
||||
byte[] totalPucks = new byte[]{0};
|
||||
StringBuilder currentDigits = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < input.length(); i++) {
|
||||
char c = input.charAt(i);
|
||||
char standardC = (c >= '0' && c <= '9') ? (char) (c - '0' + '0') : c;
|
||||
|
||||
if (Character.isDigit(standardC)) {
|
||||
currentDigits.append(standardC);
|
||||
} else if (MULTIPLIERS.containsKey(standardC)) {
|
||||
if (currentDigits.length() == 0) continue;
|
||||
|
||||
long multiplier = MULTIPLIERS.get(standardC);
|
||||
long sliceVal = Long.parseLong(currentDigits.toString()) * multiplier;
|
||||
byte[] blockBytes = GmMonetaryParser.parseBase10ToBytes(Long.toString(sliceVal));
|
||||
|
||||
// Unless explicitly marked as a standalone Puck entry, assume Gaju
|
||||
if (!forcePuck && standardC != 'P' && standardC != 'p') {
|
||||
blockBytes = multiplyByGajuFactor(blockBytes);
|
||||
}
|
||||
totalPucks = addAtomicPuckArrays(totalPucks, blockBytes, false);
|
||||
currentDigits.setLength(0); // Flush buffers!
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDigits.length() > 0) {
|
||||
byte[] remainingBytes = GmMonetaryParser.parseBase10ToBytes(currentDigits.toString());
|
||||
if (!forcePuck) remainingBytes = multiplyByGajuFactor(remainingBytes);
|
||||
totalPucks = addAtomicPuckArrays(totalPucks, remainingBytes, false);
|
||||
}
|
||||
|
||||
return totalPucks;
|
||||
}
|
||||
|
||||
private static byte[] multiplyByGajuFactor(byte[] base) {
|
||||
java.math.BigInteger b = new java.math.BigInteger(base);
|
||||
return b.multiply(java.math.BigInteger.valueOf(ONE_GAJU_FACTOR)).toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] addAtomicPuckArrays(byte[] a, byte[] b, boolean makeNegative) {
|
||||
java.math.BigInteger biA = new java.math.BigInteger(a);
|
||||
java.math.BigInteger biB = new java.math.BigInteger(b);
|
||||
java.math.BigInteger sum = biA.add(biB);
|
||||
if (makeNegative) sum = sum.negate();
|
||||
return sum.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -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