diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b29e7d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: ci + +on: + push: + branches: ["**"] + tags: ["v*"] + pull_request: + +# The Go binding links rgbconsignment/lib/_/librgb_consignment.a, +# and those archives are committed so that `go get` works without a Rust +# toolchain. `libs` rebuilds all four whenever the Rust side changes and +# `commit-libs` pushes them back to the branch; `test` runs against whatever +# archive is committed. + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - run: cargo fmt --check + - run: cargo test + - run: go vet ./rgbconsignment && go test ./rgbconsignment + + libs: + # Only the Rust side changes the archive; skip on Go/doc-only pushes. + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + targets: "x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu" + - os: macos-15 + targets: "aarch64-apple-darwin x86_64-apple-darwin" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: tj-actions/changed-files@v45 + id: rust + with: + files: | + src/** + Cargo.toml + Cargo.lock + scripts/build-lib.sh + - if: steps.rust.outputs.any_changed == 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.targets }} + - if: steps.rust.outputs.any_changed == 'true' + uses: Swatinem/rust-cache@v2 + with: + key: lib-${{ matrix.os }} + - if: steps.rust.outputs.any_changed == 'true' && runner.os == 'Linux' + name: Cross toolchain for aarch64 + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + { + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" + echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" + echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" + } >> "$GITHUB_ENV" + - if: steps.rust.outputs.any_changed == 'true' + uses: actions/setup-go@v5 + with: + go-version: "1.22" + - if: steps.rust.outputs.any_changed == 'true' + name: Build archives + run: | + for t in ${{ matrix.targets }}; do ./scripts/build-lib.sh "$t"; done + - if: steps.rust.outputs.any_changed == 'true' + uses: actions/upload-artifact@v4 + with: + name: libs-${{ matrix.os }} + path: rgbconsignment/lib/*/librgb_consignment.a + if-no-files-found: error + + commit-libs: + needs: libs + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + - uses: actions/download-artifact@v4 + with: + pattern: libs-* + path: rgbconsignment/lib + merge-multiple: true + - name: Commit rebuilt archives + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: rebuild static archives [skip ci]" + file_pattern: rgbconsignment/lib/*/librgb_consignment.a diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml index 4f436c6..825c68f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,13 @@ serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" thiserror = "2.0" +[lib] +name = "rgb_consignment" +crate-type = ["rlib", "cdylib", "staticlib"] + [features] default = ["cli"] -cli = ["dep:clap", "dep:env_logger", "dep:log", "dep:serde_json"] +cli = ["dep:clap", "dep:env_logger", "dep:log"] [dependencies] amplify.workspace = true @@ -31,12 +35,23 @@ log = { workspace = true, optional = true } rgb-ops.workspace = true rgb-strict-types.workspace = true serde.workspace = true -serde_json = { workspace = true, optional = true } -thiserror.workspace = true - -[dev-dependencies] serde_json.workspace = true +thiserror.workspace = true [[bin]] name = "rgb-consignment" required-features = ["cli"] + +[profile.release] +strip = "debuginfo" + +# Profile for the static archive shipped with the Go binding +# (scripts/build-lib.sh). LTO only takes effect when the staticlib is the sole +# crate type, hence `cargo rustc --crate-type staticlib` there; it internalises +# everything except the C ABI and drops the rest (56 MB -> ~11 MB). Thin LTO +# builds in ~1.5 min; fat saves another 2 MB but takes ~15 min. +# `panic = "unwind"` (the default) must stay: the C ABI turns panics into +# error strings via catch_unwind instead of aborting the host process. +[profile.lib] +inherits = "release" +lto = "thin" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..300ccb9 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: rust go test libs + +LIB := rgbconsignment/lib/$(shell go env GOHOSTOS)_$(shell go env GOHOSTARCH)/librgb_consignment.a +RUST_SOURCES := $(wildcard src/*.rs) Cargo.toml Cargo.lock + +# Host-platform archive only; CI rebuilds and commits all four platforms. +rust: $(LIB) + +$(LIB): $(RUST_SOURCES) + ./scripts/build-lib.sh + +go: rust + go test ./rgbconsignment + +test: rust + cargo test + go test ./rgbconsignment diff --git a/README.md b/README.md new file mode 100644 index 0000000..442851c --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# rgb-consignment + +Parses binary RGB consignment files (`.rgb`: Transfer, Contract or Kit) into a +flat summary: genesis, mint/transfer transitions with their fungible +allocations, terminals and witness transactions. + +Three entry points share one parser: + +| Entry point | Where | +|---|---| +| Rust crate `rgb_consignment::parse` | `src/` | +| CLI `rgb-consignment ` (JSON to stdout) | `src/main.rs` | +| Go package `rgbconsignment` (cgo, static link) | `rgbconsignment/` | + +## Go binding + +```go +import "github.com/UTEXO-Protocol/consignment-utils/rgbconsignment" + +info, err := rgbconsignment.Parse(consignmentBytes) +if err != nil { /* invalid consignment */ } +for _, w := range info.Witnesses { + for _, t := range w.Transitions { + fmt.Println(t.OpID, t.TransitionType, t.FungibleAllocations) + } +} +``` + +The Go package links `rgbconsignment/lib/_/librgb_consignment.a` +statically, so `go get` needs no Rust toolchain and the resulting binary has no +runtime dependency on a shared library. The archives are committed (about +11 MB each, LTO-stripped to the two exported symbols) for `darwin_arm64`, +`darwin_amd64`, `linux_arm64` and `linux_amd64`. + +After changing the Rust side, rebuild the host archive with `make rust` +(`scripts/build-lib.sh ` cross-compiles the others); CI rebuilds all +four on push and commits them back to the branch, so a local rebuild is only +needed to test. + +```sh +make rust # host archive via cargo rustc --profile lib --crate-type staticlib +make test # cargo test + go test ./rgbconsignment +``` + +Linking two Rust static archives into one Go binary can clash on Rust runtime +symbols; this archive exports its Rust runtime like any other staticlib. + +### C ABI + +`include/rgb_consignment.h` exposes two functions: + +- `rgb_consignment_parse(data, len, &err)` returns a JSON document matching the + Rust `ConsignmentInfo` type, or `NULL` with `err` set. +- `rgb_consignment_string_free(s)` releases either string. + +Rust panics never cross the boundary: they are caught and reported through +`err` as `internal error (panic): ...`. + +## Test fixtures + +`testdata/ifa-contract.rgb` is `ifa-example.rgb` from the `rgb-schemas` crate +(Apache-2.0). It is used by both `tests/fixtures.rs` and +`rgbconsignment/fixtures_test.go`. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4088d36 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/UTEXO-Protocol/consignment-utils + +go 1.22 diff --git a/include/rgb_consignment.h b/include/rgb_consignment.h new file mode 100644 index 0000000..6ebce96 --- /dev/null +++ b/include/rgb_consignment.h @@ -0,0 +1,27 @@ +#ifndef RGB_CONSIGNMENT_H +#define RGB_CONSIGNMENT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Parse a binary RGB consignment (Transfer / Contract / Kit). + * + * On success returns a newly allocated JSON string and sets *err_out to NULL. + * On failure returns NULL and *err_out is a newly allocated error message. + * + * Both strings must be freed with rgb_consignment_string_free. + */ +char *rgb_consignment_parse(const uint8_t *data, size_t len, char **err_out); + +void rgb_consignment_string_free(char *s); + +#ifdef __cplusplus +} +#endif + +#endif /* RGB_CONSIGNMENT_H */ diff --git a/rgbconsignment/fixtures_test.go b/rgbconsignment/fixtures_test.go new file mode 100644 index 0000000..b8512d8 --- /dev/null +++ b/rgbconsignment/fixtures_test.go @@ -0,0 +1,106 @@ +package rgbconsignment + +import ( + "os" + "path/filepath" + "testing" +) + +const ifaFixture = "../testdata/ifa-contract.rgb" + +func TestParseFileIFAContract(t *testing.T) { + info, err := ParseFile(filepath.FromSlash(ifaFixture)) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if !info.IsContract() || info.Kind != KindContract { + t.Fatalf("kind = %q, want %q", info.Kind, KindContract) + } + if info.Genesis == nil { + t.Fatal("genesis is nil") + } + g := info.Genesis + + strEq(t, "contract_id", g.ContractID, "rgb:jpyfP_3m-zroPnJm-2J9qFHO-7ZjimUC-yH4A96u-oHthc64") + strEq(t, "schema_id", g.SchemaID, "rgb:sch:p6H_wtDgei9HHUVLjKW0tNdHHFLhfHxrn9QX_QQUE78#scale-year-shave") + strEq(t, "chain_net", g.ChainNet, "tb4") + strEq(t, "issuer", g.Issuer, "ssi:anonymous") + strEq(t, "ffv", g.FFV, "RGB/1.0") + if g.Timestamp != 1768417034 { + t.Errorf("timestamp = %d", g.Timestamp) + } + if g.Name == nil || *g.Name != "Test asset" { + t.Errorf("name = %v", g.Name) + } + if g.Ticker == nil || *g.Ticker != "TEST" { + t.Errorf("ticker = %v", g.Ticker) + } + if g.Precision == nil || *g.Precision != 8 { + t.Errorf("precision = %v", g.Precision) + } + if g.Details != nil { + t.Errorf("details = %q, want nil", *g.Details) + } + if g.GlobalStateCount != 5 || g.AssignmentCount != 2 { + t.Errorf("counts = %d/%d, want 5/2", g.GlobalStateCount, g.AssignmentCount) + } + + if len(g.FungibleAllocations) != 2 { + t.Fatalf("fungible_allocations len = %d, want 2", len(g.FungibleAllocations)) + } + a := g.FungibleAllocations[0] + if a.AssignmentType != 4000 || a.Total != 100000 || len(a.Entries) != 1 { + t.Errorf("alloc[0] = %+v", a) + } + e := a.Entries[0] + if e.Amount != 100000 || e.Seal.Kind != SealRevealed || e.Seal.Vout != 1 { + t.Errorf("entry[0] = %+v", e) + } + if e.Seal.Txid == nil || *e.Seal.Txid != "14295d5bb1a191cdb6286dc0944df938421e3dfcbf0811353ccac4100c2068c5" { + t.Errorf("seal txid = %v", e.Seal.Txid) + } + b := g.FungibleAllocations[1] + if b.AssignmentType != 4010 || b.Total != 50000 { + t.Errorf("alloc[1] = %+v", b) + } + + strEq(t, "top-level schema_id", info.SchemaID, g.SchemaID) + if len(info.Terminals) != 0 || len(info.Witnesses) != 0 || info.BundleCount != 0 { + t.Errorf("terminals/witnesses/bundles = %d/%d/%d, want 0/0/0", + len(info.Terminals), len(info.Witnesses), info.BundleCount) + } + if info.ScriptCount != 3 || info.TypesCount != 39 { + t.Errorf("script/types = %d/%d, want 3/39", info.ScriptCount, info.TypesCount) + } +} + +func TestParseRejectsTruncatedFixture(t *testing.T) { + data, err := os.ReadFile(filepath.FromSlash(ifaFixture)) + if err != nil { + t.Fatal(err) + } + if _, err := Parse(data[:len(data)/2]); err == nil { + t.Fatal("expected error for truncated consignment") + } +} + +func BenchmarkParseIFAContract(b *testing.B) { + data, err := os.ReadFile(filepath.FromSlash(ifaFixture)) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := Parse(data); err != nil { + b.Fatal(err) + } + } +} + +func strEq(t *testing.T, what, got, want string) { + t.Helper() + if got != want { + t.Errorf("%s = %q, want %q", what, got, want) + } +} diff --git a/rgbconsignment/lib/darwin_amd64/librgb_consignment.a b/rgbconsignment/lib/darwin_amd64/librgb_consignment.a new file mode 100644 index 0000000..c6c979c Binary files /dev/null and b/rgbconsignment/lib/darwin_amd64/librgb_consignment.a differ diff --git a/rgbconsignment/lib/darwin_arm64/librgb_consignment.a b/rgbconsignment/lib/darwin_arm64/librgb_consignment.a new file mode 100644 index 0000000..d824b15 Binary files /dev/null and b/rgbconsignment/lib/darwin_arm64/librgb_consignment.a differ diff --git a/rgbconsignment/lib/linux_amd64/librgb_consignment.a b/rgbconsignment/lib/linux_amd64/librgb_consignment.a new file mode 100644 index 0000000..60dbdb0 Binary files /dev/null and b/rgbconsignment/lib/linux_amd64/librgb_consignment.a differ diff --git a/rgbconsignment/lib/linux_arm64/librgb_consignment.a b/rgbconsignment/lib/linux_arm64/librgb_consignment.a new file mode 100644 index 0000000..4bbbf67 Binary files /dev/null and b/rgbconsignment/lib/linux_arm64/librgb_consignment.a differ diff --git a/rgbconsignment/parse.go b/rgbconsignment/parse.go new file mode 100644 index 0000000..09c3fcc --- /dev/null +++ b/rgbconsignment/parse.go @@ -0,0 +1,68 @@ +// Package rgbconsignment is a Go binding for the rgb-consignment Rust library. +// +// Build the native archive first (`make rust` or `go generate ./rgbconsignment`), +// then call [Parse] or [ParseFile]. +package rgbconsignment + +/* +#cgo CFLAGS: -I${SRCDIR}/../include +#cgo darwin,arm64 LDFLAGS: ${SRCDIR}/lib/darwin_arm64/librgb_consignment.a +#cgo darwin,amd64 LDFLAGS: ${SRCDIR}/lib/darwin_amd64/librgb_consignment.a +#cgo linux,arm64 LDFLAGS: ${SRCDIR}/lib/linux_arm64/librgb_consignment.a +#cgo linux,amd64 LDFLAGS: ${SRCDIR}/lib/linux_amd64/librgb_consignment.a +#cgo darwin LDFLAGS: -framework CoreFoundation -framework Security -liconv +#cgo linux LDFLAGS: -ldl -lm -lpthread +#include +#include "rgb_consignment.h" +*/ +import "C" + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "unsafe" +) + +//go:generate ../scripts/build-lib.sh + +// Parse decodes a binary RGB consignment (Transfer, Contract, or Kit). +func Parse(data []byte) (*Info, error) { + var errOut *C.char + var ptr *C.uint8_t + n := C.size_t(len(data)) + if n > 0 { + ptr = (*C.uint8_t)(unsafe.Pointer(&data[0])) + } + + raw := C.rgb_consignment_parse(ptr, n, &errOut) + runtime.KeepAlive(data) + + if errOut != nil { + defer C.rgb_consignment_string_free(errOut) + return nil, fmt.Errorf("rgb consignment: %s", C.GoString(errOut)) + } + if raw == nil { + return nil, fmt.Errorf("rgb consignment: empty result") + } + defer C.rgb_consignment_string_free(raw) + + // One strlen and one copy: GoBytes lands directly in the []byte Unmarshal takes. + buf := C.GoBytes(unsafe.Pointer(raw), C.int(C.strlen(raw))) + + var info Info + if err := json.Unmarshal(buf, &info); err != nil { + return nil, fmt.Errorf("rgb consignment: decode json: %w", err) + } + return &info, nil +} + +// ParseFile reads path and parses it as a consignment. +func ParseFile(path string) (*Info, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return Parse(data) +} diff --git a/rgbconsignment/parse_test.go b/rgbconsignment/parse_test.go new file mode 100644 index 0000000..dd6e1cc --- /dev/null +++ b/rgbconsignment/parse_test.go @@ -0,0 +1,75 @@ +package rgbconsignment + +import ( + "encoding/json" + "testing" +) + +func TestParseRejectsGarbage(t *testing.T) { + _, err := Parse([]byte("not a consignment")) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestParseRejectsEmpty(t *testing.T) { + _, err := Parse(nil) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestJSONKinds(t *testing.T) { + cases := []struct { + raw string + kind Kind + }{ + { + raw: `{"kind":"transfer","version":3,"schema_id":"s","bundle_count":0,"script_count":0,"types_count":0,"genesis":{"contract_id":"c","schema_id":"s","chain_net":"bc","timestamp":1,"issuer":"","ffv":"RGB/1.0","global_state_count":0,"assignment_count":0,"fungible_allocations":[]}}`, + kind: KindTransfer, + }, + { + raw: `{"kind":"kit","version":3,"schema_count":1,"script_count":0,"types_count":0}`, + kind: KindKit, + }, + } + + for _, tc := range cases { + var info Info + if err := json.Unmarshal([]byte(tc.raw), &info); err != nil { + t.Fatalf("%s: %v", tc.kind, err) + } + if info.Kind != tc.kind { + t.Fatalf("got %q, want %q", info.Kind, tc.kind) + } + } +} + +func TestTransitionMetadataDecodesAndLooksUp(t *testing.T) { + raw := `{"op_id":"op","transition_type":8010,"input_count":1,"fungible_allocations":[],` + + `"metadata":[{"meta_type":1001,"value_hex":"1027000000000000"},{"meta_type":1003,"value_hex":"` + + "000000000000000000000000abababababababababababababababababababab" + `"}]}` + var tr TransitionInfo + if err := json.Unmarshal([]byte(raw), &tr); err != nil { + t.Fatal(err) + } + amount, ok := tr.Meta(1001) + if !ok || len(amount) != 8 || amount[0] != 0x10 || amount[1] != 0x27 { + t.Fatalf("MS_BURNED_ASSET bytes = %x, ok=%v", amount, ok) + } + recipient, ok := tr.Meta(1003) + if !ok || len(recipient) != 32 || recipient[12] != 0xab { + t.Fatalf("MS_BURN_RECIPIENT bytes = %x, ok=%v", recipient, ok) + } + if _, ok := tr.Meta(9999); ok { + t.Fatal("unknown meta type must not be found") + } + + var none TransitionInfo + if err := json.Unmarshal([]byte(`{"op_id":"x","transition_type":10000,"input_count":0,"fungible_allocations":[]}`), &none); err != nil { + t.Fatal(err) + } + if len(none.Metadata) != 0 { + t.Fatalf("expected no metadata, got %+v", none.Metadata) + } +} diff --git a/rgbconsignment/types.go b/rgbconsignment/types.go new file mode 100644 index 0000000..8b63dc3 --- /dev/null +++ b/rgbconsignment/types.go @@ -0,0 +1,124 @@ +package rgbconsignment + +import "encoding/hex" + +// Kind is the top-level consignment container type. +type Kind string + +const ( + KindTransfer Kind = "transfer" + KindContract Kind = "contract" + KindKit Kind = "kit" +) + +// Info is the flat summary returned by Parse. Transfer and Contract share +// the same fields; Kit only fills Version / SchemaCount / ScriptCount / +// TypesCount. +// +// Info is a decode target for the parser's JSON, not a wire format of its +// own: fields the Rust side requires per kind are marked omitempty so one Go +// struct can hold every kind, so re-marshaling an Info is not guaranteed to +// deserialize back into the Rust ConsignmentInfo. +type Info struct { + Kind Kind `json:"kind"` + + Version uint8 `json:"version"` + SchemaID string `json:"schema_id,omitempty"` + BundleCount uint32 `json:"bundle_count,omitempty"` + ScriptCount uint32 `json:"script_count"` + TypesCount uint32 `json:"types_count"` + SchemaCount uint32 `json:"schema_count,omitempty"` + + Genesis *GenesisInfo `json:"genesis,omitempty"` + Terminals []TerminalInfo `json:"terminals,omitempty"` + Witnesses []WitnessInfo `json:"witnesses,omitempty"` +} + +func (i Info) IsTransfer() bool { return i.Kind == KindTransfer } +func (i Info) IsContract() bool { return i.Kind == KindContract } +func (i Info) IsKit() bool { return i.Kind == KindKit } + +type GenesisInfo struct { + ContractID string `json:"contract_id"` + SchemaID string `json:"schema_id"` + ChainNet string `json:"chain_net"` + Timestamp int64 `json:"timestamp"` + Issuer string `json:"issuer"` + FFV string `json:"ffv"` + GlobalStateCount uint32 `json:"global_state_count"` + AssignmentCount uint32 `json:"assignment_count"` + FungibleAllocations []FungibleAllocation `json:"fungible_allocations"` + Name *string `json:"name,omitempty"` + Ticker *string `json:"ticker,omitempty"` + Precision *uint8 `json:"precision,omitempty"` + Details *string `json:"details,omitempty"` +} + +type FungibleAllocation struct { + AssignmentType uint16 `json:"assignment_type"` + Entries []FungibleEntry `json:"entries"` + Total uint64 `json:"total"` +} + +type FungibleEntry struct { + Amount uint64 `json:"amount"` + Seal Seal `json:"seal"` +} + +type SealKind string + +const ( + SealRevealed SealKind = "revealed" + SealConfidential SealKind = "confidential" +) + +type Seal struct { + Kind SealKind `json:"kind"` + Txid *string `json:"txid,omitempty"` + Vout uint32 `json:"vout"` + SecretSeal string `json:"secret_seal,omitempty"` +} + +type TransitionInfo struct { + OpID string `json:"op_id"` + TransitionType uint16 `json:"transition_type"` + InputCount uint32 `json:"input_count"` + FungibleAllocations []FungibleAllocation `json:"fungible_allocations"` + // Metadata carries the transition's schema-defined metadata fields + // (for BFA: MS_BURNED_ASSET=1001, MS_BURN_RECIPIENT=1003) as raw + // strict-encoded bytes. Empty when the transition has none. + Metadata []MetaEntry `json:"metadata,omitempty"` +} + +// MetaEntry is one metadata field of a transition. +type MetaEntry struct { + MetaType uint16 `json:"meta_type"` + // ValueHex is the raw strict-encoded value, lowercase hex without 0x. + ValueHex string `json:"value_hex"` +} + +// Meta returns the raw bytes of the metadata field metaType, if present. +func (t TransitionInfo) Meta(metaType uint16) ([]byte, bool) { + for _, m := range t.Metadata { + if m.MetaType == metaType { + b, err := hex.DecodeString(m.ValueHex) + if err != nil { + return nil, false + } + return b, true + } + } + return nil, false +} + +type TerminalInfo struct { + BundleID string `json:"bundle_id"` + SecretSealCount uint32 `json:"secret_seal_count"` +} + +type WitnessInfo struct { + Txid string `json:"txid"` + TransitionCount uint32 `json:"transition_count"` + InputCount uint32 `json:"input_count"` + Transitions []TransitionInfo `json:"transitions"` +} diff --git a/scripts/build-lib.sh b/scripts/build-lib.sh new file mode 100755 index 0000000..8ca0f48 --- /dev/null +++ b/scripts/build-lib.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Build the static archive for the Go binding and place it under +# rgbconsignment/lib/_/librgb_consignment.a, where the per-platform +# #cgo directives in rgbconsignment/parse.go expect it. The archives are +# committed, so `go get` consumers need no Rust toolchain. +# +# scripts/build-lib.sh # host platform +# scripts/build-lib.sh x86_64-apple-darwin # cross-compile (rustup target add first) +# scripts/build-lib.sh aarch64-unknown-linux-gnu +set -eu + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +target="${1:-${CARGO_BUILD_TARGET:-}}" +if [ -n "$target" ]; then + case "$target" in + aarch64-apple-darwin) label=darwin_arm64 ;; + x86_64-apple-darwin) label=darwin_amd64 ;; + aarch64-unknown-linux-gnu) label=linux_arm64 ;; + x86_64-unknown-linux-gnu) label=linux_amd64 ;; + *) echo "unsupported target $target" >&2; exit 1 ;; + esac +else + # No explicit target: the archive is for the host, whatever GOOS/GOARCH say. + label="$(go env GOHOSTOS)_$(go env GOHOSTARCH)" +fi + +case "$label" in + darwin_*) + # Keep the objects linkable from Go toolchains targeting an older SDK, + # otherwise ld warns "built for newer macOS version" per object. + export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}" + ;; +esac + +# --no-default-features drops the CLI-only deps (clap, env_logger). +# --crate-type staticlib alone lets LTO in [profile.lib] strip everything +# unreachable from the exported C ABI. +cargo rustc --profile lib --lib --no-default-features --crate-type staticlib \ + ${target:+--target "$target"} + +target_dir="${CARGO_TARGET_DIR:-target}" +src="$target_dir/${target:+$target/}lib/librgb_consignment.a" +dest="$root/rgbconsignment/lib/$label/librgb_consignment.a" + +mkdir -p "$(dirname "$dest")" +cp "$src" "$dest" +echo "copied $src -> $dest ($(du -h "$dest" | cut -f1))" diff --git a/src/ffi.rs b/src/ffi.rs new file mode 100644 index 0000000..ed0f71a --- /dev/null +++ b/src/ffi.rs @@ -0,0 +1,132 @@ +//! C ABI for language bindings. +//! +//! `rgb_consignment_parse` accepts a binary consignment and returns a +//! newly-allocated JSON document matching [`crate::ConsignmentInfo`]. + +use std::ffi::{CString, c_char}; +use std::panic::{self, AssertUnwindSafe}; +use std::ptr; + +/// Parse a binary RGB consignment and return a newly allocated JSON C string. +/// +/// On success `*err_out` is set to null. On failure the return value is null +/// and `*err_out` is a newly allocated error message. `err_out` may be null +/// when the caller does not need the message. +/// +/// Both strings must be freed with [`rgb_consignment_string_free`]. +/// +/// # Safety +/// +/// * `data` must be valid for `len` bytes, or `data` may be null when `len` is 0. +/// * `err_out` must be null or a valid writable pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rgb_consignment_parse( + data: *const u8, + len: usize, + err_out: *mut *mut c_char, +) -> *mut c_char { + let set_err = |msg: &str| { + if !err_out.is_null() { + unsafe { + *err_out = to_cstring(msg); + } + } + }; + set_err_null(err_out); + + if data.is_null() && len != 0 { + set_err("null data pointer"); + return ptr::null_mut(); + } + + let bytes = if data.is_null() || len == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(data, len) } + }; + + // A panic must never unwind across the C boundary: with `extern "C"` that + // aborts the whole host process (e.g. a Go service). Turn it into an error. + let result = panic::catch_unwind(AssertUnwindSafe(|| parse_to_json(bytes))); + + match result { + Ok(Ok(json)) => to_cstring(&json), + Ok(Err(msg)) => { + set_err(&msg); + ptr::null_mut() + } + Err(payload) => { + let msg = payload + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".to_string()); + set_err(&format!("internal error (panic): {msg}")); + ptr::null_mut() + } + } +} + +fn set_err_null(err_out: *mut *mut c_char) { + if !err_out.is_null() { + unsafe { + *err_out = ptr::null_mut(); + } + } +} + +fn parse_to_json(bytes: &[u8]) -> Result { + let info = crate::parse(bytes).map_err(|e| e.to_string())?; + serde_json::to_string(&info).map_err(|e| format!("serialize: {e}")) +} + +/// Free a string returned by [`rgb_consignment_parse`]. +/// +/// # Safety +/// +/// `s` must be null or a pointer previously returned by this crate's C ABI. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rgb_consignment_string_free(s: *mut c_char) { + if s.is_null() { + return; + } + drop(unsafe { CString::from_raw(s) }); +} + +fn to_cstring(s: &str) -> *mut c_char { + CString::new(s.replace('\0', "")) + .expect("NUL bytes already stripped") + .into_raw() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CStr; + use std::ptr; + + #[test] + fn parse_garbage_sets_error() { + let data = b"nope"; + let mut err = ptr::null_mut(); + let json = unsafe { rgb_consignment_parse(data.as_ptr(), data.len(), &mut err) }; + assert!(json.is_null()); + assert!(!err.is_null()); + let msg = unsafe { CStr::from_ptr(err) }.to_string_lossy(); + assert!(msg.contains("not a valid RGB consignment"), "{msg}"); + unsafe { rgb_consignment_string_free(err) }; + } + + #[test] + fn null_err_out_is_accepted() { + let data = b"nope"; + let json = unsafe { rgb_consignment_parse(data.as_ptr(), data.len(), ptr::null_mut()) }; + assert!(json.is_null(), "garbage still fails without an err_out"); + + let fixture = include_bytes!("../testdata/ifa-contract.rgb"); + let json = + unsafe { rgb_consignment_parse(fixture.as_ptr(), fixture.len(), ptr::null_mut()) }; + assert!(!json.is_null(), "valid input must parse without an err_out"); + unsafe { rgb_consignment_string_free(json) }; + } +} diff --git a/src/info.rs b/src/info.rs index 3cfe655..5f879a8 100644 --- a/src/info.rs +++ b/src/info.rs @@ -156,6 +156,20 @@ pub struct TransitionInfo { /// Outputs grouped by assignment type. Empty if the transition only /// moves declarative or structured (non-fungible) state. pub fungible_allocations: Vec, + /// Schema-defined metadata carried by the transition (e.g. BFA + /// `MS_BURNED_ASSET` = 1001, `MS_BURN_RECIPIENT` = 1003), raw + /// strict-encoded bytes as lowercase hex. Empty when there is none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub metadata: Vec, +} + +/// One metadata field of a transition. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct MetaEntry { + /// Schema-defined metadata type id. + pub meta_type: u16, + /// Raw strict-encoded value, lowercase hex without prefix. + pub value_hex: String, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -177,3 +191,73 @@ pub struct WitnessInfo { /// State transitions in this bundle, with their fungible outputs. pub transitions: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transfer_json_uses_internally_tagged_kind() { + let info = ConsignmentInfo::Transfer(TransferInfo { + version: 3, + genesis: GenesisInfo { + contract_id: "c".into(), + schema_id: "s".into(), + chain_net: "bc".into(), + timestamp: 1, + issuer: String::new(), + ffv: "RGB/1.0".into(), + global_state_count: 0, + assignment_count: 0, + fungible_allocations: vec![], + name: Some("Name".into()), + ticker: Some("TKN".into()), + precision: Some(8), + details: None, + }, + schema_id: "s".into(), + terminals: vec![], + witnesses: vec![], + bundle_count: 0, + script_count: 0, + types_count: 0, + }); + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains(r#""kind":"transfer""#), "{json}"); + assert!(json.contains(r#""ticker":"TKN""#), "{json}"); + assert!(!json.contains("details"), "{json}"); + } + + #[test] + fn transition_metadata_round_trips_and_is_omitted_when_empty() { + let mut t = TransitionInfo { + op_id: "op".into(), + transition_type: 8010, + input_count: 1, + fungible_allocations: vec![], + metadata: vec![], + }; + let json = serde_json::to_string(&t).unwrap(); + assert!(!json.contains("metadata"), "{json}"); + let back: TransitionInfo = serde_json::from_str(&json).unwrap(); + assert!(back.metadata.is_empty()); + + t.metadata = vec![ + MetaEntry { + meta_type: 1001, + value_hex: "1027000000000000".into(), + }, + MetaEntry { + meta_type: 1003, + value_hex: "00".repeat(12) + &"ab".repeat(20), + }, + ]; + let json = serde_json::to_string(&t).unwrap(); + assert!( + json.contains(r#""meta_type":1001,"value_hex":"1027000000000000""#), + "{json}" + ); + let back: TransitionInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(back.metadata, t.metadata); + } +} diff --git a/src/lib.rs b/src/lib.rs index dedbc8f..290c419 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,12 +10,16 @@ //! let info = rgb_consignment::parse(&bytes).unwrap(); //! println!("{}", serde_json::to_string_pretty(&info).unwrap()); //! ``` +//! +//! A C ABI (`rgb_consignment_parse`) is exported for language bindings. +//! The Go wrapper lives in `rgbconsignment`. +mod ffi; mod info; mod parse; pub use info::{ ConsignmentInfo, ConsignmentKind, ContractInfo, FungibleAllocation, FungibleEntry, GenesisInfo, - KitInfo, SealInfo, TerminalInfo, TransferInfo, TransitionInfo, WitnessInfo, + KitInfo, MetaEntry, SealInfo, TerminalInfo, TransferInfo, TransitionInfo, WitnessInfo, }; pub use parse::{ParseError, parse}; diff --git a/src/parse.rs b/src/parse.rs index ef8055f..bc7f409 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -7,7 +7,7 @@ use strict_types::{FieldName, SemId, Ty, TypeSystem, VariantName}; use crate::info::{ ConsignmentInfo, ContractInfo, FungibleAllocation, FungibleEntry, GenesisInfo, KitInfo, - SealInfo, TerminalInfo, TransferInfo, TransitionInfo, WitnessInfo, + MetaEntry, SealInfo, TerminalInfo, TransferInfo, TransitionInfo, WitnessInfo, }; // Well-known global-state-type IDs from `rgb-schemas`. @@ -313,7 +313,25 @@ fn transition_info(kt: &KnownTransition) -> TransitionInfo { transition_type: t.transition_type.to_inner(), input_count: t.inputs.len() as u32, fungible_allocations: fungible_allocations(&t.assignments), + metadata: t + .metadata + .iter() + .map(|(ty, value)| MetaEntry { + meta_type: ty.to_inner(), + value_hex: to_hex(value.as_ref()), + }) + .collect(), + } +} + +fn to_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(DIGITS[(b >> 4) as usize] as char); + out.push(DIGITS[(b & 0x0f) as usize] as char); } + out } fn fungible_allocations(asgs: &Assignments) -> Vec { diff --git a/testdata/ifa-contract.rgb b/testdata/ifa-contract.rgb new file mode 100644 index 0000000..f072de9 Binary files /dev/null and b/testdata/ifa-contract.rgb differ diff --git a/tests/fixtures.rs b/tests/fixtures.rs new file mode 100644 index 0000000..c7ffac8 --- /dev/null +++ b/tests/fixtures.rs @@ -0,0 +1,89 @@ +//! End-to-end tests over real consignment files in `testdata/`. + +use std::ffi::{CStr, c_char}; +use std::ptr; + +use rgb_consignment::{ConsignmentInfo, ConsignmentKind, SealInfo, parse}; + +const IFA_CONTRACT: &[u8] = include_bytes!("../testdata/ifa-contract.rgb"); + +unsafe extern "C" { + fn rgb_consignment_parse(data: *const u8, len: usize, err_out: *mut *mut c_char) + -> *mut c_char; + fn rgb_consignment_string_free(s: *mut c_char); +} + +#[test] +fn ifa_contract_parses() { + let info = parse(IFA_CONTRACT).expect("fixture must parse"); + assert_eq!(info.kind(), ConsignmentKind::Contract); + + let ConsignmentInfo::Contract(c) = info else { + panic!("expected contract"); + }; + let g = &c.genesis; + assert_eq!( + g.contract_id, + "rgb:jpyfP_3m-zroPnJm-2J9qFHO-7ZjimUC-yH4A96u-oHthc64" + ); + assert_eq!(g.chain_net, "tb4"); + assert_eq!(g.timestamp, 1768417034); + assert_eq!(g.name.as_deref(), Some("Test asset")); + assert_eq!(g.ticker.as_deref(), Some("TEST")); + assert_eq!(g.precision, Some(8)); + assert_eq!(g.details, None); + assert_eq!(g.global_state_count, 5); + assert_eq!(g.assignment_count, 2); + + assert_eq!(g.fungible_allocations.len(), 2); + let a = &g.fungible_allocations[0]; + assert_eq!(a.assignment_type, 4000); + assert_eq!(a.total, 100_000); + assert_eq!(a.entries.len(), 1); + match &a.entries[0].seal { + SealInfo::Revealed { txid, vout } => { + assert_eq!( + txid.as_deref(), + Some("14295d5bb1a191cdb6286dc0944df938421e3dfcbf0811353ccac4100c2068c5") + ); + assert_eq!(*vout, 1); + } + other => panic!("unexpected seal {other:?}"), + } + assert_eq!(g.fungible_allocations[1].assignment_type, 4010); + assert_eq!(g.fungible_allocations[1].total, 50_000); + + assert_eq!(c.schema_id, g.schema_id); + assert!(c.terminals.is_empty()); + assert!(c.witnesses.is_empty()); + assert_eq!(c.bundle_count, 0); + assert_eq!(c.script_count, 3); + assert_eq!(c.types_count, 39); +} + +#[test] +fn ifa_contract_via_c_abi() { + let mut err = ptr::null_mut(); + let json = + unsafe { rgb_consignment_parse(IFA_CONTRACT.as_ptr(), IFA_CONTRACT.len(), &mut err) }; + assert!( + err.is_null(), + "unexpected error: {}", + unsafe { CStr::from_ptr(err) }.to_string_lossy() + ); + assert!(!json.is_null()); + + let text = unsafe { CStr::from_ptr(json) }.to_str().unwrap().to_owned(); + unsafe { rgb_consignment_string_free(json) }; + + // JSON produced by the C ABI must round-trip into the public Rust type. + let info: ConsignmentInfo = serde_json::from_str(&text).unwrap(); + assert_eq!(info.kind(), ConsignmentKind::Contract); + assert!(text.contains(r#""ticker":"TEST""#), "{text}"); +} + +#[test] +fn truncated_fixture_is_rejected() { + let cut = &IFA_CONTRACT[..IFA_CONTRACT.len() / 2]; + assert!(parse(cut).is_err()); +}