Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: ci

on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:

# The Go binding links rgbconsignment/lib/<GOOS>_<GOARCH>/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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/target
25 changes: 20 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <file.rgb>` (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/<GOOS>_<GOARCH>/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 <target>` 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`.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/UTEXO-Protocol/consignment-utils

go 1.22
27 changes: 27 additions & 0 deletions include/rgb_consignment.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#ifndef RGB_CONSIGNMENT_H
#define RGB_CONSIGNMENT_H

#include <stddef.h>
#include <stdint.h>

#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 */
106 changes: 106 additions & 0 deletions rgbconsignment/fixtures_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading