diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b29e7d..8e05199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,8 @@ on: # 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. +# archive is committed, so `libs` tests (native) or links (cross) each archive +# before uploading it. jobs: test: @@ -37,11 +38,17 @@ jobs: fail-fast: false matrix: include: + # cross: the Go env that links the second, cross-built target. - os: ubuntu-24.04 targets: "x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu" + cross: "GOARCH=arm64 CC=aarch64-linux-gnu-gcc" - os: macos-15 targets: "aarch64-apple-darwin x86_64-apple-darwin" + cross: "GOARCH=amd64" runs-on: ${{ matrix.os }} + # Both legs see the same commit, so either one's answer is the job's. + outputs: + built: ${{ steps.rust.outputs.any_changed }} steps: - uses: actions/checkout@v4 - uses: tj-actions/changed-files@v45 @@ -52,6 +59,7 @@ jobs: Cargo.toml Cargo.lock scripts/build-lib.sh + .github/workflows/ci.yml - if: steps.rust.outputs.any_changed == 'true' uses: dtolnay/rust-toolchain@stable with: @@ -64,7 +72,7 @@ jobs: name: Cross toolchain for aarch64 run: | sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu + sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross { echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" @@ -74,10 +82,21 @@ jobs: uses: actions/setup-go@v5 with: go-version: "1.22" + # Upload only what this runner built: the checkout also holds the other + # platform's committed archives, and merge-multiple would write both copies. + - if: steps.rust.outputs.any_changed == 'true' + name: Remove the committed archives + run: rm -f rgbconsignment/lib/*/librgb_consignment.a - if: steps.rust.outputs.any_changed == 'true' name: Build archives run: | for t in ${{ matrix.targets }}; do ./scripts/build-lib.sh "$t"; done + # The cross-built archive cannot run here, so it is only linked. + - if: steps.rust.outputs.any_changed == 'true' + name: Test the native archive, link the cross-built one + run: | + go test ./rgbconsignment + env CGO_ENABLED=1 ${{ matrix.cross }} go test -c -o /dev/null ./rgbconsignment - if: steps.rust.outputs.any_changed == 'true' uses: actions/upload-artifact@v4 with: @@ -87,7 +106,8 @@ jobs: commit-libs: needs: libs - if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + # Without a rebuild there is nothing to commit; running anyway would delete the archives. + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') && needs.libs.outputs.built == 'true' runs-on: ubuntu-24.04 permissions: contents: write @@ -95,11 +115,36 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} + # Start from no file so a missing artifact cannot leave a stale archive behind. + - name: Remove the committed archives + run: rm -f rgbconsignment/lib/*/librgb_consignment.a - uses: actions/download-artifact@v4 with: pattern: libs-* path: rgbconsignment/lib merge-multiple: true + # Names all four archives, so a missing artifact fails here instead of + # reaching the commit as a deleted archive. + - name: Check all four archives arrived intact + run: | + python3 - rgbconsignment/lib/{darwin,linux}_{amd64,arm64}/librgb_consignment.a <<'PY' + import os + import sys + + for path in sys.argv[1:]: + assert os.path.exists(path), f"{path}: missing, its libs artifact did not arrive" + data = open(path, "rb").read() + assert data[:8] == b"!\n", f"{path}: not an ar archive" + off, size = 8, 0 + while off < len(data): + assert off + 60 <= len(data) and data[off + 58:off + 60] == b"`\n", f"{path}: bad member header at {off}" + size = int(data[off + 48:off + 58].strip() or 0) + off += 60 + size + size % 2 + # Only an odd-sized last member may drop its padding byte. + assert off == len(data) or (size % 2 and off == len(data) + 1), \ + f"{path}: archive ends at {len(data)}, its members at {off}" + print(path, "ok") + PY - name: Commit rebuilt archives uses: stefanzweifel/git-auto-commit-action@v5 with: diff --git a/Cargo.lock b/Cargo.lock index 1b4bd65..37a3bb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,8 +23,6 @@ dependencies = [ "amplify_syn", "ascii", "rand 0.8.7", - "serde", - "stringly_conversions", "wasm-bindgen", ] @@ -57,7 +55,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afed304556696656d2d71495e1e5f2c4b524a3fb6eb0f2f3778ffc482a40b8a8" dependencies = [ - "serde", "wasm-bindgen", ] @@ -148,9 +145,6 @@ name = "ascii" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" -dependencies = [ - "serde", -] [[package]] name = "autocfg" @@ -827,15 +821,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2b8f3a258db515d5e91a904ce4ae3f73e091149b90cadbdb93d210bee07f63b" -[[package]] -name = "nonasync" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d81f7335a3fa37124e24461d6164485760d4c056dd92a81a70c23fc28c2b63c8" -dependencies = [ - "amplify", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -857,12 +842,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1046,16 +1025,14 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rgb-aluvm" -version = "0.11.1-rc.4" +version = "0.11.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf4d92478fcff567ff0bd9f18684fd954f8426c74f16e5517254b0a2d34023a" +checksum = "21de8dc75b4ce90dffe66cff585053e9f5d2abe6436589c95e6308f0605195f4" dependencies = [ "amplify", "baid64", "blake3", - "getrandom 0.3.4", "half", - "paste", "rgb-ascii-armor", "rgb-strict-encoding", "rgb-strict-types", @@ -1067,9 +1044,9 @@ dependencies = [ [[package]] name = "rgb-ascii-armor" -version = "1.0.2" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcde5a129540e5911c24930fd854ea658a715b50a690d254273088f907a63516" +checksum = "0b3a739537822793adce1f9539bb3fae3d54a16f409e8353ac85281bdfcec078" dependencies = [ "amplify", "baid64", @@ -1080,9 +1057,9 @@ dependencies = [ [[package]] name = "rgb-consensus" -version = "0.11.1-rc.11" +version = "0.11.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cee44070883ac31a112814ee635623b0bde44a2afab71ff3c591700a878a76d9" +checksum = "cd5d6c21f60f24976d8b983b6b5925cd87a4865af8ad6d965d45360518bbfd24" dependencies = [ "amplify", "baid64", @@ -1095,11 +1072,11 @@ dependencies = [ "hex-conservative 1.2.0", "rand 0.9.5", "rgb-aluvm", + "rgb-ascii-armor", "rgb-strict-encoding", "rgb-strict-types", "ripemd", - "secp256k1 0.31.1", - "secp256k1 0.32.0-beta.2", + "secp256k1 0.33.1", "serde", "sha2 0.11.0", "wasm-bindgen", @@ -1122,9 +1099,9 @@ dependencies = [ [[package]] name = "rgb-invoicing" -version = "0.11.1-rc.11" +version = "0.11.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b44153a2411b2d1ddc859a011061f95ec07acf32e70c414193838244207e232e" +checksum = "76692ae2017e3b4789d2539dcf483b5e5e9f4468030d9ffb619aabeab7cd43b4" dependencies = [ "amplify", "baid64", @@ -1140,16 +1117,14 @@ dependencies = [ [[package]] name = "rgb-ops" -version = "0.11.1-rc.11" +version = "0.11.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e454f98d8ab2ef78a2e0ba924e2a5c67b5ed55ba1664f4a6ee269d2f271353bc" +checksum = "a0b0b332a32be0e41ce328d534d559e67a0add6381d250bdc188e44eb8777ca7" dependencies = [ "amplify", "baid64", "chrono", "getrandom 0.3.4", - "nonasync", - "rand 0.9.5", "rgb-aluvm", "rgb-ascii-armor", "rgb-consensus", @@ -1163,9 +1138,9 @@ dependencies = [ [[package]] name = "rgb-strict-encoding" -version = "1.0.2" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f68326e14d4b627f86634ea324510135cb551e27c5583a74811fa230b0cf34d" +checksum = "43cf06a3afea7b29f92f279a5fabb39215d1287f796a8d1a75ce77dd4f807fd4" dependencies = [ "amplify", "bitcoin", @@ -1176,9 +1151,9 @@ dependencies = [ [[package]] name = "rgb-strict-encoding-derive" -version = "1.0.2" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33367536cfef03cb582cd9f8f25e193c2298d3ec3112eb59018a376b465f1ed9" +checksum = "473d4bc563f8db593abf7213f82fafc5bc667125f2c39222cd66ead369e60421" dependencies = [ "amplify_syn", "heck", @@ -1189,9 +1164,9 @@ dependencies = [ [[package]] name = "rgb-strict-types" -version = "1.0.2" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e920bdf61b662a3743572a03696b0d88992eeb7d4f7afa0902915968327b3ce6" +checksum = "5efea5be04a66a22f96580fb8c7fd9558f7c5d74039c096bb024e4f325f4529a" dependencies = [ "amplify", "baid64", @@ -1240,24 +1215,12 @@ dependencies = [ [[package]] name = "secp256k1" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" -dependencies = [ - "bitcoin_hashes", - "rand 0.9.5", - "secp256k1-sys 0.11.0", - "serde", -] - -[[package]] -name = "secp256k1" -version = "0.32.0-beta.2" +version = "0.33.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5fdc7d6e800869d3fd60ff857c479bf0a83ea7bf44b389e64461e844204994" +checksum = "d7f404a8dab7a7a5a631e741d8699aa9c8e1d689fc26ccf897c7187565490b69" dependencies = [ "rand 0.9.5", - "secp256k1-sys 0.12.0", + "secp256k1-sys 0.14.1", "serde", ] @@ -1272,18 +1235,9 @@ dependencies = [ [[package]] name = "secp256k1-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" -dependencies = [ - "cc", -] - -[[package]] -name = "secp256k1-sys" -version = "0.12.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d3be00697c88c00fe102af8dc316038cc2062eab8da646e7463f4c0e70ca9fd" +checksum = "f6b2992d4a3cd244539a7d5d0966aadbe5ca7fb868a5d7e38c29499b9e709bbd" dependencies = [ "cc", ] @@ -1340,16 +1294,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_str_helpers" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b744a7c94f2f3785496af33a0d93857dfc0c521e25c38e993e9c5bb45f09c841" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -1397,16 +1341,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "stringly_conversions" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff63080f492dd4d289ffcaed8d7ece38adfb423db910eb342c0e04d409536a7a" -dependencies = [ - "paste", - "serde_str_helpers", -] - [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 825c68f..dfc9bba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,14 +7,16 @@ license = "Apache-2.0" [workspace.dependencies] -amplify = { version = "4.8.1", default-features = false } +amplify = { version = "=4.8.1", default-features = false } clap = { version = "4.5.4", features = ["derive"] } env_logger = "0.11.3" log = "0.4.21" -rgb-ops = { version = "0.11.1-rc.10", default-features = false, features = [ +# Exact pins keep the RGB crate versions rgb-lib 3f571ac and the enclave decode +# with, so a newer prerelease cannot change the strict encoding under us. +rgb-ops = { version = "=0.11.2-rc.1", default-features = false, features = [ "serde", ] } -rgb-strict-types = "1.0.1" +rgb-strict-types = "=1.0.5" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" thiserror = "2.0" diff --git a/README.md b/README.md index 442851c..7851a3c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,33 @@ # 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. +Parses binary RGB consignment files into a flat summary: genesis, mint / +transfer / burn transitions with their fungible allocations, metadata and +global state, terminals and witness transactions. + +Built on RGB **0.11.2-rc.1** (`rgb-ops =0.11.2-rc.1`, `rgb-strict-types +=1.0.5`, `amplify =4.8.1`, the versions the RGB-Tools BFA release of rgb-lib, +`rgb-tools-devs/rgb-lib` commit `3f571ac`, locks). It reads its V1 containers: + +| Magic | Container | Written by rgb-lib as | +|---|---|---| +| `RGB\0TFR` | Transfer | `consignment_out`, received `rcv_compose.rgbc` | +| `RGB\0CON` | Contract | - (rgb-ops `Contract::save`) | +| `RGB\0VTF` | Validated transfer | `*.valid.rgbc` | +| `RGB\0VCO` | Validated contract | the issued asset file `assets/` | + +The validated containers append their validation status as JSON; it is not +read. Legacy V0 consignments (RGB 0.11.1 and older) and schema definition +files (`SD2`) are rejected. A V1 consignment carries no schema or type +system, so asset name, ticker, precision and details are decoded against the +known `rgb-ops` types (`AssetSpec`, or `Name` / `Precision` / `Details` for +CFA); nothing is validated. Three entry points share one parser: | Entry point | Where | |---|---| | Rust crate `rgb_consignment::parse` | `src/` | -| CLI `rgb-consignment ` (JSON to stdout) | `src/main.rs` | +| CLI `rgb-consignment ` (JSON to stdout) | `src/main.rs` | | Go package `rgbconsignment` (cgo, static link) | `rgbconsignment/` | ## Go binding @@ -26,6 +44,42 @@ for _, w := range info.Witnesses { } ``` +### Global state and metadata + +Each transition carries its `Metadata []MetaEntry` and `Globals +[]GlobalEntry`, and genesis its `Globals`, as raw strict-encoded bytes in +lowercase hex. `Meta(metaType)` and `Global(globalType)` return the decoded +bytes of the first value of that type. Nothing is validated, so a crafted +genesis or transition can repeat a global the schema caps at one: read those +with `GlobalOnce`, which fails on a repeat and wraps `ErrGlobalNotFound` when +absent. + +```go +burnedAsset, err := burn.GlobalOnce(2015) // u64 LE +burnReason, err := burn.GlobalOnce(3009) // 32 raw bytes, no length prefix +afterBlock, ok := mint.Meta(1003) // u64 LE +location, err := info.Genesis.GlobalOnce(3008) +``` + +A revealed seal without a `txid` points into its bundle's witness tx. For a +transition seal that is the enclosing `WitnessInfo`; for a terminal seal it is +a `WitnessInfo` whose `BundleID` equals the terminal's `BundleID`. The parser +keeps every bundle and the bundle id does not depend on the witness tx, so +unvalidated input can match more than one `WitnessInfo`. + +BFA ids in rgb-schemas 0.11.2-rc.1 (`src/lib.rs`): + +| Id | Name | Value layout | +|---|---|---| +| 8013 | `TS_MINT` | globals `issuedSupply` 2010 (u64 LE), metadata `afterBlock` 1003 (u64 LE) | +| 8010 | `TS_BURN` | globals `burnedAsset` 2015 (u64 LE), `burnReason` 3009 (32 bytes); both optional | +| 10000 | `TS_TRANSFER` | | +| 4000 | `OS_ASSET` | fungible | +| 4013 | `OS_MINT` | declarative (`mintRight`) | +| 3008 | `GS_BRIDGE_LOCATION` | genesis: tag `00` (Evm), chain id u64 LE, 20-byte address | + +### Static archives + 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 @@ -58,6 +112,15 @@ Rust panics never cross the boundary: they are caught and reported through ## 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`. +- `testdata/{bfa,ifa,cfa}-contract.rgb` are the `*-example.rgb` contracts of + the `rgb-schemas` 0.11.2-rc.1 crate (Apache-2.0), in `VCO` containers. +- `testdata/bfa-transfer-synthetic.rgb` is a BFA mint + burn transfer built + by `tests/synthetic` from the rc.1 types and saved as rgb-lib saves one. + Its anchors are dummies: it exercises the parser, not validation, and is no + substitute for transfers produced by a release wallet. After changing the + builder, regenerate it with + `cargo test --test fixtures regenerate_synthetic_transfer -- --ignored`; + `committed_synthetic_transfer_matches_builder` fails until you do. +- `testdata/legacy-v0-bfa-burn.rgb` is a V0 BFA burn transfer from the + pre-cutover rgb-lib fork (enclave-signer `bfa_burn_consignment.rgbc`). The + tests assert it is rejected, which the hard cutover relies on. diff --git a/include/rgb_consignment.h b/include/rgb_consignment.h index 6ebce96..58a7a62 100644 --- a/include/rgb_consignment.h +++ b/include/rgb_consignment.h @@ -9,7 +9,7 @@ extern "C" { #endif /* - * Parse a binary RGB consignment (Transfer / Contract / Kit). + * Parse a binary RGB consignment (Transfer / Contract, V1 container). * * 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. diff --git a/rgbconsignment/fixtures_test.go b/rgbconsignment/fixtures_test.go index b8512d8..629406e 100644 --- a/rgbconsignment/fixtures_test.go +++ b/rgbconsignment/fixtures_test.go @@ -1,82 +1,172 @@ package rgbconsignment import ( + "bytes" + "encoding/binary" + "encoding/hex" "os" "path/filepath" + "strings" "testing" ) -const ifaFixture = "../testdata/ifa-contract.rgb" +// The contracts are the rgb-schemas 0.11.2-rc.1 examples. The transfer is +// synthetic (built by tests/synthetic in the Rust crate): well formed V1, but +// not produced by a release wallet and not valid against the chain. +const ( + bfaFixture = "../testdata/bfa-contract.rgb" + ifaFixture = "../testdata/ifa-contract.rgb" + transferFixture = "../testdata/bfa-transfer-synthetic.rgb" + legacyV0Fixture = "../testdata/legacy-v0-bfa-burn.rgb" +) + +// BFA ids from rgb-schemas 0.11.2-rc.1 (src/lib.rs). +const ( + tsMint uint16 = 8013 + tsBurn uint16 = 8010 + osAsset uint16 = 4000 + gsIssuedSupply uint16 = 2010 + gsBurnedAsset uint16 = 2015 + gsBridgeLoc uint16 = 3008 + gsBurnReason uint16 = 3009 + msAfterBlock uint16 = 1003 +) -func TestParseFileIFAContract(t *testing.T) { - info, err := ParseFile(filepath.FromSlash(ifaFixture)) +func parseFixture(t testing.TB, path string) *Info { + t.Helper() + info, err := ParseFile(filepath.FromSlash(path)) if err != nil { - t.Fatalf("ParseFile: %v", err) + t.Fatalf("ParseFile(%s): %v", path, err) } + return info +} - if !info.IsContract() || info.Kind != KindContract { - t.Fatalf("kind = %q, want %q", info.Kind, KindContract) - } - if info.Genesis == nil { - t.Fatal("genesis is nil") +func TestParseFileBFAContract(t *testing.T) { + info := parseFixture(t, bfaFixture) + if !info.IsContract() || info.Version != 1 { + t.Fatalf("kind/version = %q/%d, want contract/1", info.Kind, info.Version) } 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, "contract_id", g.ContractID, "rgb:UiDQRpwu-~dsy7ZH-raGXsnJ-UNiT13f-WbArf5m-jPeb8xU") + strEq(t, "schema_id", g.SchemaID, "rgb:sch:Ryl5eQZ3IHrU05e3AWdQZJR4FB5GvxZPy96cwhJiApc#amazon-norway-harris") + strEq(t, "top-level schema_id", info.SchemaID, g.SchemaID) 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.Name == nil || *g.Name != "Test asset" || g.Ticker == nil || *g.Ticker != "TEST" { + t.Errorf("name/ticker = %v/%v", g.Name, 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 len(g.FungibleAllocations) != 0 || len(info.Witnesses) != 0 || len(info.Terminals) != 0 { + t.Errorf("BFA genesis must allocate no asset and a contract has no history: %+v", info) + } +} + +func TestBFAGenesisBridgeLocation(t *testing.T) { + g := parseFixture(t, bfaFixture).Genesis + location, err := g.GlobalOnce(gsBridgeLoc) + if err != nil { + t.Fatal(err) + } + // BridgeLocation::Evm: union tag 0, chain id (u64 LE), 20-byte address. + if len(location) != 29 || location[0] != 0 { + t.Fatalf("bridgeLocation = %x", location) } - if g.GlobalStateCount != 5 || g.AssignmentCount != 2 { - t.Errorf("counts = %d/%d, want 5/2", g.GlobalStateCount, g.AssignmentCount) + if chainID := binary.LittleEndian.Uint64(location[1:9]); chainID != 1 { + t.Errorf("chain id = %d, want 1", chainID) } + strEq(t, "address", hex.EncodeToString(location[9:]), strings.Repeat("deadbeef", 5)) +} +func TestParseFileIFAContractAllocations(t *testing.T) { + g := parseFixture(t, ifaFixture).Genesis 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) + if a.AssignmentType != osAsset || a.Total != 100000 || len(a.Entries) != 1 { + t.Fatalf("alloc[0] = %+v", a) } e := a.Entries[0] - if e.Amount != 100000 || e.Seal.Kind != SealRevealed || e.Seal.Vout != 1 { + if e.Seal.Kind != SealRevealed || e.Seal.Vout != 1 || e.Seal.Txid == nil || + *e.Seal.Txid != "14295d5bb1a191cdb6286dc0944df938421e3dfcbf0811353ccac4100c2068c5" { t.Errorf("entry[0] = %+v", e) } - if e.Seal.Txid == nil || *e.Seal.Txid != "14295d5bb1a191cdb6286dc0944df938421e3dfcbf0811353ccac4100c2068c5" { - t.Errorf("seal txid = %v", e.Seal.Txid) +} + +func TestParseSyntheticTransferMint(t *testing.T) { + info := parseFixture(t, transferFixture) + if !info.IsTransfer() || len(info.Witnesses) != 2 || info.BundleCount != 2 { + t.Fatalf("kind=%q witnesses=%d bundles=%d", info.Kind, len(info.Witnesses), info.BundleCount) + } + mint := info.Witnesses[0].Transitions[0] + if mint.TransitionType != tsMint { + t.Fatalf("transition type = %d, want TS_MINT", mint.TransitionType) } - b := g.FungibleAllocations[1] - if b.AssignmentType != 4010 || b.Total != 50000 { - t.Errorf("alloc[1] = %+v", b) + expectU64(t, "issuedSupply", mint.Global, gsIssuedSupply, 1000) + expectU64(t, "afterBlock", mint.Meta, msAfterBlock, 42) + if len(mint.FungibleAllocations) != 1 || mint.FungibleAllocations[0].Total != 1000 { + t.Errorf("mint allocations = %+v", mint.FungibleAllocations) } +} - 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) +func TestParseSyntheticTransferBurn(t *testing.T) { + info := parseFixture(t, transferFixture) + burn := info.Witnesses[1].Transitions[0] + if burn.TransitionType != tsBurn { + t.Fatalf("transition type = %d, want TS_BURN", burn.TransitionType) + } + expectU64(t, "burnedAsset", burn.Global, gsBurnedAsset, 400) + reason, ok := burn.Global(gsBurnReason) + want := append(make([]byte, 12), bytes.Repeat([]byte{0xab}, 20)...) + if !ok || !bytes.Equal(reason, want) { + t.Errorf("burnReason = %x, ok=%v, want %x", reason, ok, want) + } + change := burn.FungibleAllocations[0].Entries[0] + if change.Amount != 600 || change.Seal.Kind != SealConfidential || change.Seal.SecretSeal == "" { + t.Errorf("change = %+v", change) + } +} + +func TestParseSyntheticTransferTerminals(t *testing.T) { + info := parseFixture(t, transferFixture) + if len(info.Terminals) != 1 || len(info.Terminals[0].Seals) != 2 { + t.Fatalf("terminals = %+v", info.Terminals) + } + concealed, revealed := info.Terminals[0].Seals[0], info.Terminals[0].Seals[1] + if concealed.Kind != SealConfidential || revealed.Kind != SealRevealed || revealed.Vout != 2 { + t.Errorf("terminal seals = %+v / %+v", concealed, revealed) + } + change := info.Witnesses[1].Transitions[0].FungibleAllocations[0].Entries[0] + strEq(t, "concealed terminal", concealed.SecretSeal, change.Seal.SecretSeal) + // The revealed terminal has no txid: it lives in the burn bundle's witness tx. + strEq(t, "terminal bundle", info.Terminals[0].BundleID, info.Witnesses[1].BundleID) + if info.Witnesses[0].BundleID == info.Witnesses[1].BundleID { + t.Errorf("mint and burn witnesses share bundle id %s", info.Witnesses[0].BundleID) + } +} + +func TestParseRejectsLegacyV0Transfer(t *testing.T) { + _, err := ParseFile(filepath.FromSlash(legacyV0Fixture)) + if err == nil || !strings.Contains(err.Error(), "ConsignmentVer") { + t.Fatalf("legacy V0 transfer must be rejected on its version, got err=%v", err) + } +} + +func expectU64(t *testing.T, what string, lookup func(uint16) ([]byte, bool), id uint16, want uint64) { + t.Helper() + raw, ok := lookup(id) + if !ok || len(raw) != 8 { + t.Errorf("%s = %x, ok=%v, want 8 bytes", what, raw, ok) + return } - if info.ScriptCount != 3 || info.TypesCount != 39 { - t.Errorf("script/types = %d/%d, want 3/39", info.ScriptCount, info.TypesCount) + if got := binary.LittleEndian.Uint64(raw); got != want { + t.Errorf("%s = %d, want %d", what, got, want) } } func TestParseRejectsTruncatedFixture(t *testing.T) { - data, err := os.ReadFile(filepath.FromSlash(ifaFixture)) + data, err := os.ReadFile(filepath.FromSlash(transferFixture)) if err != nil { t.Fatal(err) } @@ -85,8 +175,8 @@ func TestParseRejectsTruncatedFixture(t *testing.T) { } } -func BenchmarkParseIFAContract(b *testing.B) { - data, err := os.ReadFile(filepath.FromSlash(ifaFixture)) +func BenchmarkParseSyntheticTransfer(b *testing.B) { + data, err := os.ReadFile(filepath.FromSlash(transferFixture)) if err != nil { b.Fatal(err) } diff --git a/rgbconsignment/lib/darwin_amd64/librgb_consignment.a b/rgbconsignment/lib/darwin_amd64/librgb_consignment.a index f15349c..5e8cbdc 100644 Binary files a/rgbconsignment/lib/darwin_amd64/librgb_consignment.a 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 index ee9be31..09fedb3 100644 Binary files a/rgbconsignment/lib/darwin_arm64/librgb_consignment.a 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 index ca21850..04e4e64 100644 Binary files a/rgbconsignment/lib/linux_amd64/librgb_consignment.a 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 index cd92b6c..3ae911c 100644 Binary files a/rgbconsignment/lib/linux_arm64/librgb_consignment.a and b/rgbconsignment/lib/linux_arm64/librgb_consignment.a differ diff --git a/rgbconsignment/parse.go b/rgbconsignment/parse.go index 09c3fcc..b5060a8 100644 --- a/rgbconsignment/parse.go +++ b/rgbconsignment/parse.go @@ -27,7 +27,7 @@ import ( //go:generate ../scripts/build-lib.sh -// Parse decodes a binary RGB consignment (Transfer, Contract, or Kit). +// Parse decodes a binary RGB V1 consignment (Transfer or Contract, plain or validated). func Parse(data []byte) (*Info, error) { var errOut *C.char var ptr *C.uint8_t diff --git a/rgbconsignment/parse_test.go b/rgbconsignment/parse_test.go index dd6e1cc..45f76ad 100644 --- a/rgbconsignment/parse_test.go +++ b/rgbconsignment/parse_test.go @@ -2,6 +2,8 @@ package rgbconsignment import ( "encoding/json" + "errors" + "strings" "testing" ) @@ -25,12 +27,12 @@ func TestJSONKinds(t *testing.T) { 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":[]}}`, + raw: `{"kind":"transfer","version":1,"schema_id":"s","bundle_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":[]},"terminals":[],"witnesses":[]}`, kind: KindTransfer, }, { - raw: `{"kind":"kit","version":3,"schema_count":1,"script_count":0,"types_count":0}`, - kind: KindKit, + raw: `{"kind":"contract","version":1,"schema_id":"s","bundle_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":[]},"terminals":[],"witnesses":[]}`, + kind: KindContract, }, } @@ -46,30 +48,144 @@ func TestJSONKinds(t *testing.T) { } func TestTransitionMetadataDecodesAndLooksUp(t *testing.T) { + raw := `{"op_id":"op","transition_type":8013,"input_count":1,"fungible_allocations":[],` + + `"metadata":[{"meta_type":1003,"value_hex":"2a00000000000000"}]}` + var tr TransitionInfo + if err := json.Unmarshal([]byte(raw), &tr); err != nil { + t.Fatal(err) + } + afterBlock, ok := tr.Meta(1003) + if !ok || len(afterBlock) != 8 || afterBlock[0] != 0x2a { + t.Fatalf("afterBlock bytes = %x, ok=%v", afterBlock, ok) + } + if _, ok := tr.Meta(9999); ok { + t.Fatal("unknown meta type must not be found") + } +} + +func TestTransitionGlobalsDecodeAndLookUp(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":"` + + `"globals":[{"global_type":2015,"value_hex":"9001000000000000"},{"global_type":3009,"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) + amount, ok := tr.Global(2015) + if !ok || len(amount) != 8 || amount[0] != 0x90 || amount[1] != 0x01 { + t.Fatalf("burnedAsset 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) + reason, ok := tr.Global(3009) + if !ok || len(reason) != 32 || reason[11] != 0 || reason[12] != 0xab { + t.Fatalf("burnReason bytes = %x, ok=%v", reason, ok) } - if _, ok := tr.Meta(9999); ok { - t.Fatal("unknown meta type must not be found") + if _, ok := tr.Global(2010); ok { + t.Fatal("absent global type must not be found") } +} + +func TestGlobalReturnsFirstValueOfType(t *testing.T) { + g := GenesisInfo{Globals: []GlobalEntry{ + {GlobalType: 2010, ValueHex: "01"}, + {GlobalType: 2010, ValueHex: "02"}, + }} + b, ok := g.Global(2010) + if !ok || len(b) != 1 || b[0] != 0x01 { + t.Fatalf("Global(2010) = %x, ok=%v, want first value 01", b, ok) + } +} + +func TestGlobalOnceReturnsTheOnlyValue(t *testing.T) { + tr := TransitionInfo{Globals: []GlobalEntry{ + {GlobalType: 2015, ValueHex: "9001000000000000"}, + {GlobalType: 3009, ValueHex: "ab"}, + }} + b, err := tr.GlobalOnce(2015) + if err != nil || len(b) != 8 || b[0] != 0x90 { + t.Fatalf("GlobalOnce(2015) = %x, err=%v", b, err) + } +} + +func TestGlobalOnceRejectsRepeatedType(t *testing.T) { + tr := TransitionInfo{OpID: "op", Globals: []GlobalEntry{ + {GlobalType: 2015, ValueHex: "9001000000000000"}, + {GlobalType: 2015, ValueHex: "0100000000000000"}, + }} + b, err := tr.GlobalOnce(2015) + if err == nil || !strings.Contains(err.Error(), "appears 2 times") { + t.Fatalf("two burnedAsset values must be rejected, got %x, err=%v", b, err) + } + if errors.Is(err, ErrGlobalNotFound) { + t.Fatalf("a repeat is not a missing global: %v", err) + } +} + +func TestGlobalOnceReportsMissingType(t *testing.T) { + tr := TransitionInfo{OpID: "op", Globals: []GlobalEntry{{GlobalType: 2015, ValueHex: "00"}}} + if _, err := tr.GlobalOnce(3009); !errors.Is(err, ErrGlobalNotFound) { + t.Fatalf("err = %v, want ErrGlobalNotFound", err) + } +} + +func TestGlobalOnceRejectsMalformedHex(t *testing.T) { + tr := TransitionInfo{OpID: "op", Globals: []GlobalEntry{{GlobalType: 3009, ValueHex: "zz"}}} + if b, err := tr.GlobalOnce(3009); err == nil || errors.Is(err, ErrGlobalNotFound) { + t.Fatalf("malformed hex must fail as such, got %x, err=%v", b, err) + } +} + +func TestGenesisGlobalOnceReturnsTheOnlyValue(t *testing.T) { + g := GenesisInfo{Globals: []GlobalEntry{ + {GlobalType: 2000, ValueHex: "00"}, + {GlobalType: 3008, ValueHex: "0001"}, + }} + b, err := g.GlobalOnce(3008) + if err != nil || len(b) != 2 || b[1] != 0x01 { + t.Fatalf("GlobalOnce(3008) = %x, err=%v", b, err) + } +} + +func TestGenesisGlobalOnceRejectsRepeatedType(t *testing.T) { + g := GenesisInfo{ContractID: "rgb:c", Globals: []GlobalEntry{ + {GlobalType: 3008, ValueHex: "00"}, + {GlobalType: 3008, ValueHex: "01"}, + }} + b, err := g.GlobalOnce(3008) + if err == nil || !strings.Contains(err.Error(), "genesis rgb:c: global 3008 appears 2 times") { + t.Fatalf("two bridgeLocation values must be rejected, got %x, err=%v", b, err) + } + if errors.Is(err, ErrGlobalNotFound) { + t.Fatalf("a repeat is not a missing global: %v", err) + } +} + +func TestGenesisGlobalOnceReportsMissingType(t *testing.T) { + g := GenesisInfo{ContractID: "rgb:c", Globals: []GlobalEntry{{GlobalType: 2000, ValueHex: "00"}}} + if _, err := g.GlobalOnce(3008); !errors.Is(err, ErrGlobalNotFound) { + t.Fatalf("err = %v, want ErrGlobalNotFound", err) + } +} + +func TestGenesisGlobalOnceRejectsMalformedHex(t *testing.T) { + g := GenesisInfo{Globals: []GlobalEntry{{GlobalType: 3008, ValueHex: "zz"}}} + if b, err := g.GlobalOnce(3008); err == nil || errors.Is(err, ErrGlobalNotFound) { + t.Fatalf("malformed hex must fail as such, got %x, err=%v", b, err) + } +} + +func TestGlobalRejectsMalformedHex(t *testing.T) { + tr := TransitionInfo{Globals: []GlobalEntry{{GlobalType: 2015, ValueHex: "zz"}}} + if b, ok := tr.Global(2015); ok { + t.Fatalf("malformed hex must not decode, got %x", b) + } +} +func TestEmptyGlobalsAndMetadataDecodeAsNil(t *testing.T) { 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) + if len(none.Metadata) != 0 || len(none.Globals) != 0 { + t.Fatalf("expected no metadata or globals, got %+v / %+v", none.Metadata, none.Globals) } } diff --git a/rgbconsignment/types.go b/rgbconsignment/types.go index 8b63dc3..778843b 100644 --- a/rgbconsignment/types.go +++ b/rgbconsignment/types.go @@ -1,6 +1,10 @@ package rgbconsignment -import "encoding/hex" +import ( + "encoding/hex" + "errors" + "fmt" +) // Kind is the top-level consignment container type. type Kind string @@ -8,26 +12,21 @@ 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. +// the same fields; a contract has no terminals or witnesses. // // 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. +// own: re-marshaling an Info is not guaranteed to deserialize back into the +// Rust ConsignmentInfo. type Info struct { Kind Kind `json:"kind"` + // Version is the container version, 1 for the V1 containers of RGB 0.11.2. 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"` + SchemaID string `json:"schema_id"` + BundleCount uint32 `json:"bundle_count"` Genesis *GenesisInfo `json:"genesis,omitempty"` Terminals []TerminalInfo `json:"terminals,omitempty"` @@ -36,7 +35,6 @@ type Info struct { 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"` @@ -52,6 +50,21 @@ type GenesisInfo struct { Ticker *string `json:"ticker,omitempty"` Precision *uint8 `json:"precision,omitempty"` Details *string `json:"details,omitempty"` + // Globals is the global state declared at genesis, one entry per value + // (for BFA: bridgeLocation=3008). + Globals []GlobalEntry `json:"globals,omitempty"` +} + +// Global returns the raw bytes of the first genesis value of globalType, if present. +// It does not notice repeats; use GlobalOnce for a type the schema caps at one. +func (g GenesisInfo) Global(globalType uint16) ([]byte, bool) { + return firstGlobal(g.Globals, globalType) +} + +// GlobalOnce returns the raw bytes of the only genesis value of globalType, with +// the same rules and errors as TransitionInfo.GlobalOnce. +func (g GenesisInfo) GlobalOnce(globalType uint16) ([]byte, error) { + return onlyGlobal("genesis "+g.ContractID, g.Globals, globalType) } type FungibleAllocation struct { @@ -85,9 +98,13 @@ type TransitionInfo struct { 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. + // (for a BFA mint: afterBlock=1003) as raw strict-encoded bytes. Empty + // when the transition has none. Metadata []MetaEntry `json:"metadata,omitempty"` + // Globals carries the global state the transition commits, one entry + // per value (for a BFA burn: burnedAsset=2015, burnReason=3009). Empty + // when the transition has none. + Globals []GlobalEntry `json:"globals,omitempty"` } // MetaEntry is one metadata field of a transition. @@ -111,13 +128,76 @@ func (t TransitionInfo) Meta(metaType uint16) ([]byte, bool) { return nil, false } +// GlobalEntry is one global state value of an operation. +type GlobalEntry struct { + GlobalType uint16 `json:"global_type"` + // ValueHex is the raw strict-encoded value, lowercase hex without 0x. + ValueHex string `json:"value_hex"` +} + +// Global returns the raw bytes of the first value of globalType, if present. +// It does not notice repeats; use GlobalOnce for a type the schema caps at one. +func (t TransitionInfo) Global(globalType uint16) ([]byte, bool) { + return firstGlobal(t.Globals, globalType) +} + +// ErrGlobalNotFound reports an operation without a value of the requested global type. +var ErrGlobalNotFound = errors.New("global not found") + +// GlobalOnce returns the raw bytes of the only value of globalType. It fails on a +// repeat because the parser does not validate the schema's Once / NoneOrOnce rule. +func (t TransitionInfo) GlobalOnce(globalType uint16) ([]byte, error) { + return onlyGlobal("transition "+t.OpID, t.Globals, globalType) +} + +// onlyGlobal backs both GlobalOnce methods; op names the operation in errors. +func onlyGlobal(op string, globals []GlobalEntry, globalType uint16) ([]byte, error) { + var values []string + for _, g := range globals { + if g.GlobalType == globalType { + values = append(values, g.ValueHex) + } + } + switch len(values) { + case 0: + return nil, fmt.Errorf("%s: global %d: %w", op, globalType, ErrGlobalNotFound) + case 1: + b, err := hex.DecodeString(values[0]) + if err != nil { + return nil, fmt.Errorf("%s: global %d is not valid hex: %w", op, globalType, err) + } + return b, nil + default: + return nil, fmt.Errorf("%s: global %d appears %d times, the schema allows one", + op, globalType, len(values)) + } +} + +func firstGlobal(globals []GlobalEntry, globalType uint16) ([]byte, bool) { + for _, g := range globals { + if g.GlobalType == globalType { + b, err := hex.DecodeString(g.ValueHex) + if err != nil { + return nil, false + } + return b, true + } + } + return nil, false +} + +// TerminalInfo lists the terminal seals of one bundle: concealed for blinded +// receives, revealed for witness-vout ones. A revealed seal without a Txid sits +// in the tx of a WitnessInfo with the same BundleID; nothing is validated, so +// that can match more than one WitnessInfo. type TerminalInfo struct { - BundleID string `json:"bundle_id"` - SecretSealCount uint32 `json:"secret_seal_count"` + BundleID string `json:"bundle_id"` + Seals []Seal `json:"seals"` } type WitnessInfo struct { Txid string `json:"txid"` + BundleID string `json:"bundle_id"` TransitionCount uint32 `json:"transition_count"` InputCount uint32 `json:"input_count"` Transitions []TransitionInfo `json:"transitions"` diff --git a/src/info.rs b/src/info.rs index 5f879a8..81b7bbb 100644 --- a/src/info.rs +++ b/src/info.rs @@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize}; pub enum ConsignmentInfo { Transfer(TransferInfo), Contract(ContractInfo), - Kit(KitInfo), } impl ConsignmentInfo { @@ -14,7 +13,6 @@ impl ConsignmentInfo { match self { Self::Transfer(_) => ConsignmentKind::Transfer, Self::Contract(_) => ConsignmentKind::Contract, - Self::Kit(_) => ConsignmentKind::Kit, } } } @@ -24,16 +22,15 @@ impl ConsignmentInfo { pub enum ConsignmentKind { Transfer, Contract, - Kit, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TransferInfo { - /// Container format version (`v2`/`v3`/...). + /// Container format version (`1` for the V1 containers of RGB 0.11.2). pub version: u8, /// Genesis (issuance) info. pub genesis: GenesisInfo, - /// Schema identifier (baid64). + /// Schema identifier (baid64), the one genesis commits to. pub schema_id: String, /// History terminal seal bundles. pub terminals: Vec, @@ -41,10 +38,6 @@ pub struct TransferInfo { pub witnesses: Vec, /// Total number of transition bundles in the consignment. pub bundle_count: u32, - /// Number of validation script libraries embedded. - pub script_count: u32, - /// Number of strict types embedded. - pub types_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -55,21 +48,11 @@ pub struct ContractInfo { pub terminals: Vec, pub witnesses: Vec, pub bundle_count: u32, - pub script_count: u32, - pub types_count: u32, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct KitInfo { - pub version: u8, - pub schema_count: u32, - pub script_count: u32, - pub types_count: u32, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct GenesisInfo { - /// Contract id (baid64) — i.e. the asset id. + /// Contract id (baid64), i.e. the asset id. pub contract_id: String, /// Schema id (baid64) the contract was issued under. pub schema_id: String, @@ -81,16 +64,16 @@ pub struct GenesisInfo { pub issuer: String, /// RGB fast-forward version code (display form, e.g. `RGB/1.0`). pub ffv: String, - /// Number of global state entries declared at genesis. + /// Number of global state types declared at genesis (not values). pub global_state_count: u32, /// Number of asset-tag declarations (one per fungible state type). pub assignment_count: u32, /// Fungible allocations issued at genesis, grouped by assignment type. - /// For an NIA asset this is the issuance breakdown — `total` is the + /// For an NIA asset this is the issuance breakdown: `total` is the /// total supply. pub fungible_allocations: Vec, - /// Asset name. Decoded from `GS_NOMINAL`'s `AssetSpec` (NIA/IFA/PFA) or - /// from `GS_NAME` directly (CFA). `None` when the schema doesn't carry + /// Asset name. Decoded from `GS_NOMINAL`'s `AssetSpec` (NIA/IFA/PFA/BFA) + /// or from `GS_NAME` directly (CFA). `None` when the schema doesn't carry /// either, or decoding failed. #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, @@ -105,6 +88,10 @@ pub struct GenesisInfo { /// Free-form asset description, if present. #[serde(skip_serializing_if = "Option::is_none")] pub details: Option, + /// Global state declared at genesis, one entry per value (e.g. BFA + /// `bridgeLocation` = 3008). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub globals: Vec, } /// All fungible outputs of a single assignment type (e.g. `assetOwner`), @@ -133,21 +120,22 @@ pub struct FungibleEntry { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SealInfo { - /// Concrete `txid:vout`. For transition seals, `txid` is `None` when - /// the seal points at the witness tx of its containing bundle — - /// resolve by combining with `WitnessInfo.txid`. + /// Concrete `txid:vout`. `txid` is `None` when the seal points at the + /// witness tx of its bundle: the `WitnessInfo` holding the transition, + /// or for a terminal one with the same `bundle_id`. Nothing is validated, + /// so that can match more than one `WitnessInfo`. Revealed { #[serde(skip_serializing_if = "Option::is_none")] txid: Option, vout: u32, }, - /// Recipient seal kept hidden — only the SHA-256 hash is exposed. + /// Recipient seal kept hidden - only the SHA-256 hash is exposed. Confidential { secret_seal: String }, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TransitionInfo { - /// Operation id (baid64). + /// Operation id (hex). pub op_id: String, /// Schema-defined transition-type id. pub transition_type: u16, @@ -156,11 +144,16 @@ 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. + /// Schema-defined metadata carried by the transition (e.g. BFA mint + /// `afterBlock` = 1003), raw strict-encoded bytes as lowercase hex. + /// Empty when there is none. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub metadata: Vec, + /// Global state committed by the transition, one entry per value (e.g. + /// BFA burn `burnedAsset` = 2015, `burnReason` = 3009). Empty when there + /// is none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub globals: Vec, } /// One metadata field of a transition. @@ -172,18 +165,31 @@ pub struct MetaEntry { pub value_hex: String, } +/// One global state value of an operation. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct GlobalEntry { + /// Schema-defined global state type id. + pub global_type: u16, + /// Raw strict-encoded value, lowercase hex without prefix. + pub value_hex: String, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TerminalInfo { - /// Bundle id (baid64). + /// Bundle id (hex), as in `WitnessInfo.bundle_id`; unvalidated input can repeat + /// a bundle, so more than one `WitnessInfo` may match. pub bundle_id: String, - /// Number of secret seals listed at this terminal. - pub secret_seal_count: u32, + /// Terminal seals of this bundle: concealed for blinded receives, + /// revealed for witness-vout ones. + pub seals: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct WitnessInfo { /// Bitcoin txid the bundle commits to. pub txid: String, + /// Id (hex) of the bundle this tx witnesses. + pub bundle_id: String, /// Number of state transitions in the bundle. pub transition_count: u32, /// Number of inputs referenced from the witness tx. @@ -199,7 +205,7 @@ mod tests { #[test] fn transfer_json_uses_internally_tagged_kind() { let info = ConsignmentInfo::Transfer(TransferInfo { - version: 3, + version: 1, genesis: GenesisInfo { contract_id: "c".into(), schema_id: "s".into(), @@ -214,50 +220,76 @@ mod tests { ticker: Some("TKN".into()), precision: Some(8), details: None, + globals: vec![], }, 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}"); + assert!(!json.contains("globals"), "{json}"); } - #[test] - fn transition_metadata_round_trips_and_is_omitted_when_empty() { - let mut t = TransitionInfo { + fn bare_transition() -> TransitionInfo { + TransitionInfo { op_id: "op".into(), transition_type: 8010, input_count: 1, fungible_allocations: vec![], metadata: vec![], - }; + globals: vec![], + } + } + + #[test] + fn transition_metadata_round_trips_and_is_omitted_when_empty() { + let mut t = bare_transition(); 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, + t.metadata = vec![MetaEntry { + meta_type: 1003, + value_hex: "2a00000000000000".into(), + }]; + let json = serde_json::to_string(&t).unwrap(); + assert!( + json.contains(r#""meta_type":1003,"value_hex":"2a00000000000000""#), + "{json}" + ); + let back: TransitionInfo = serde_json::from_str(&json).unwrap(); + assert_eq!(back.metadata, t.metadata); + } + + #[test] + fn transition_globals_round_trip_and_are_omitted_when_empty() { + let mut t = bare_transition(); + let json = serde_json::to_string(&t).unwrap(); + assert!(!json.contains("globals"), "{json}"); + let back: TransitionInfo = serde_json::from_str(&json).unwrap(); + assert!(back.globals.is_empty()); + + t.globals = vec![ + GlobalEntry { + global_type: 2015, value_hex: "1027000000000000".into(), }, - MetaEntry { - meta_type: 1003, + GlobalEntry { + global_type: 3009, 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.contains(r#""global_type":2015,"value_hex":"1027000000000000""#), "{json}" ); let back: TransitionInfo = serde_json::from_str(&json).unwrap(); - assert_eq!(back.metadata, t.metadata); + assert_eq!(back.globals, t.globals); } } diff --git a/src/lib.rs b/src/lib.rs index 290c419..252d276 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! RGB consignment kit. //! -//! Accepts the binary container format produced by `rgb-std` / `rgb-lib` -//! (`.rgb` files: Transfer, Contract, or Kit) and returns a flat +//! Accepts the binary V1 containers produced by `rgb-ops` / `rgb-lib` on RGB +//! 0.11.2 (Transfer or Contract, plain or validated) and returns a flat //! [`ConsignmentInfo`] summary suitable for serialization across language //! boundaries. //! @@ -20,6 +20,6 @@ mod parse; pub use info::{ ConsignmentInfo, ConsignmentKind, ContractInfo, FungibleAllocation, FungibleEntry, GenesisInfo, - KitInfo, MetaEntry, SealInfo, TerminalInfo, TransferInfo, TransitionInfo, WitnessInfo, + GlobalEntry, MetaEntry, SealInfo, TerminalInfo, TransferInfo, TransitionInfo, WitnessInfo, }; pub use parse::{ParseError, parse}; diff --git a/src/main.rs b/src/main.rs index 8126ea4..77bdb02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::process::ExitCode; use clap::Parser; use log::{debug, error}; -/// Parses an RGB consignment (Transfer / Contract / Kit) and writes a JSON +/// Parses an RGB consignment (Transfer / Contract) and writes a JSON /// summary to stdout. With no path or `-`, reads bytes from stdin. #[derive(Parser)] #[command(name = "rgb-consignment", version, about, long_about = None)] diff --git a/src/parse.rs b/src/parse.rs index 900a95b..c3116c6 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -1,148 +1,117 @@ use amplify::Wrapper; -use rgbstd::containers::{Consignment, ConsignmentExt, Kit, UniversalFile}; -use rgbstd::schema::GlobalStateType; -use rgbstd::{Assign, Assignments, ExposedSeal, KnownTransition, Transition, TypedAssigns}; -use strict_types::value::{EnumTag, StrictNum, StrictVal}; -use strict_types::{FieldName, SemId, Ty, TypeSystem, VariantName}; +use amplify::confinement::U32 as FILE_MAX_LEN; +use rgbstd::containers::{Consignment, WitnessBundle}; +use rgbstd::stl::{AssetSpec, Details, Name}; +use rgbstd::{ + Assign, Assignments, BuilderSeal, ExposedSeal, Genesis, GlobalState, GlobalStateType, + KnownTransition, Operation, Precision, RevealedValue, TypedAssigns, +}; +use strict_types::encoding::{StreamReader, StrictDecode, StrictDeserialize}; use crate::info::{ - ConsignmentInfo, ContractInfo, FungibleAllocation, FungibleEntry, GenesisInfo, KitInfo, + ConsignmentInfo, ContractInfo, FungibleAllocation, FungibleEntry, GenesisInfo, GlobalEntry, MetaEntry, SealInfo, TerminalInfo, TransferInfo, TransitionInfo, WitnessInfo, }; // Well-known global-state-type IDs from `rgb-schemas`. // Inlined to avoid pulling the full schemas crate as a dep. -const GS_NOMINAL: u16 = 2000; // RGBContract.AssetSpec (NIA / IFA / PFA / UDA) +const GS_NOMINAL: u16 = 2000; // RGBContract.AssetSpec (NIA / IFA / PFA / UDA / BFA) const GS_NAME: u16 = 3001; // RGBContract.Name (CFA) const GS_DETAILS: u16 = 3004; // RGBContract.Details const GS_PRECISION: u16 = 3005; // RGBContract.Precision +const RGB_PREFIX: &[u8] = b"RGB\x00"; +const MAGIC_LEN: usize = 3; + #[derive(Debug, thiserror::Error)] pub enum ParseError { #[error("not a valid RGB consignment file: {0}")] Decode(String), } -impl From for ParseError { - fn from(e: rgbstd::containers::LoadError) -> Self { - Self::Decode(e.to_string()) +/// Parse a binary consignment file into a flat summary. +/// +/// Accepts the V1 containers written by RGB 0.11.2: `TFR` / `CON` and the +/// validated copies `VTF` / `VCO` that rgb-lib keeps on disk. +pub fn parse(bytes: &[u8]) -> Result { + let (magic, body) = bytes + .strip_prefix(RGB_PREFIX) + .and_then(|rest| rest.split_at_checked(MAGIC_LEN)) + .ok_or_else(|| ParseError::Decode("missing RGB file header".into()))?; + match magic { + b"TFR" | b"VTF" => Ok(ConsignmentInfo::Transfer(transfer_info(&load(body)?))), + b"CON" | b"VCO" => Ok(ConsignmentInfo::Contract(contract_info(&load(body)?))), + _ => Err(ParseError::Decode(format!( + "unsupported container type {:?}", + String::from_utf8_lossy(magic) + ))), } } -/// Parse a binary consignment file into a flat summary. -pub fn parse(bytes: &[u8]) -> Result { - let file = UniversalFile::load(bytes)?; - Ok(match file { - UniversalFile::Transfer(t) => ConsignmentInfo::Transfer(transfer_info(&t)), - UniversalFile::Contract(c) => ConsignmentInfo::Contract(contract_info(&c)), - UniversalFile::Kit(k) => ConsignmentInfo::Kit(kit_info(&k)), - }) +// The validated copies append their validation status as JSON, which is not read. +fn load(body: &[u8]) -> Result, ParseError> { + let reader = StreamReader::new::(body); + let consignment = Consignment::::strict_read(reader) + .map_err(|e| ParseError::Decode(e.to_string()))?; + if consignment.transfer != TRANSFER { + return Err(ParseError::Decode( + "container type does not match the consignment transfer flag".into(), + )); + } + Ok(consignment) } fn transfer_info(c: &Consignment) -> TransferInfo { - let (genesis, schema_id, terminals, witnesses, bundle_count, script_count, types_count) = - consignment_common(c); TransferInfo { - version: c.version as u8, - genesis, - schema_id, - terminals, - witnesses, - bundle_count, - script_count, - types_count, + version: u8::from(c.version), + genesis: genesis_info(&c.genesis), + schema_id: c.schema_id().to_string(), + terminals: terminal_infos(c), + witnesses: c.bundles.iter().map(witness_info).collect(), + bundle_count: c.bundles.len() as u32, } } fn contract_info(c: &Consignment) -> ContractInfo { - let (genesis, schema_id, terminals, witnesses, bundle_count, script_count, types_count) = - consignment_common(c); ContractInfo { - version: c.version as u8, - genesis, - schema_id, - terminals, - witnesses, - bundle_count, - script_count, - types_count, - } -} - -fn kit_info(k: &Kit) -> KitInfo { - KitInfo { - version: k.version as u8, - schema_count: k.schemata.len() as u32, - script_count: k.scripts.len() as u32, - types_count: k.types.len() as u32, + version: u8::from(c.version), + genesis: genesis_info(&c.genesis), + schema_id: c.schema_id().to_string(), + terminals: terminal_infos(c), + witnesses: c.bundles.iter().map(witness_info).collect(), + bundle_count: c.bundles.len() as u32, } } -fn consignment_common( - c: &Consignment, -) -> ( - GenesisInfo, - String, - Vec, - Vec, - u32, - u32, - u32, -) { - let schema_id = c.schema.schema_id().to_string(); - let genesis = genesis_info(c); - - let terminals = c - .terminals +fn terminal_infos(c: &Consignment) -> Vec { + c.terminals .iter() - .map(|(bundle_id, secret_seals)| TerminalInfo { + .map(|(bundle_id, seals)| TerminalInfo { bundle_id: bundle_id.to_string(), - secret_seal_count: secret_seals.len() as u32, + seals: seals.iter().map(seal_info).collect(), }) - .collect(); - - let witnesses = c - .bundles - .iter() - .map(|wb| { - let bundle = wb.bundle(); - let transitions = bundle - .known_transitions - .iter() - .map(transition_info) - .collect(); - WitnessInfo { - txid: wb.witness_id().to_string(), - transition_count: bundle.known_transitions.len() as u32, - input_count: bundle.input_map.len() as u32, - transitions, - } - }) - .collect(); - - let bundle_count = c.bundles.len() as u32; - let script_count = c.scripts.len() as u32; - let types_count = c.types.len() as u32; - - ( - genesis, - schema_id, - terminals, - witnesses, - bundle_count, - script_count, - types_count, - ) + .collect() } -fn genesis_info(c: &Consignment) -> GenesisInfo { - let g = &c.genesis; - - let mut asset = AssetMeta::default(); - asset.extract_from(c); +fn witness_info(wb: &WitnessBundle) -> WitnessInfo { + let bundle = &wb.bundle; + WitnessInfo { + txid: wb.witness_id().to_string(), + bundle_id: bundle.bundle_id().to_string(), + transition_count: bundle.known_transitions.len() as u32, + input_count: bundle.input_map.len() as u32, + transitions: bundle + .known_transitions + .iter() + .map(transition_info) + .collect(), + } +} +fn genesis_info(g: &Genesis) -> GenesisInfo { + let asset = AssetMeta::from_globals(&g.globals); GenesisInfo { - contract_id: c.contract_id().to_string(), + contract_id: g.contract_id().to_string(), schema_id: g.schema_id.to_string(), chain_net: g.chain_net.prefix().to_string(), timestamp: g.timestamp, @@ -155,13 +124,12 @@ fn genesis_info(c: &Consignment) -> GenesisInfo ticker: asset.ticker, precision: asset.precision, details: asset.details, + globals: global_entries(&g.globals), } } -/// Decoded asset metadata pulled from genesis global state. -/// `None` means the schema doesn't carry the field, the data -/// was malformed, or the type system couldn't decode it. -#[derive(Default)] +/// Asset metadata decoded from genesis global state. `None` means the schema +/// doesn't carry the field or its value did not decode. struct AssetMeta { name: Option, ticker: Option, @@ -170,144 +138,45 @@ struct AssetMeta { } impl AssetMeta { - fn extract_from(&mut self, c: &Consignment) { - // Try the AssetSpec composite first (NIA/IFA/PFA/UDA). It carries - // ticker, name, precision and optional details in a single value. - if let Some((spec, sem_id)) = decode_global(c, GlobalStateType::with(GS_NOMINAL)) { - self.absorb_asset_spec(&c.types, &spec, sem_id); - } - - // Fall back to / overlay scalar fields used by schemas like CFA. - if self.name.is_none() - && let Some((v, _)) = decode_global(c, GlobalStateType::with(GS_NAME)) - { - self.name = strict_val_to_string(&v); - } - - if self.precision.is_none() - && let Some((v, sem_id)) = decode_global(c, GlobalStateType::with(GS_PRECISION)) - { - self.precision = enum_val_to_ord(&c.types, &v, sem_id); - } - - if self.details.is_none() - && let Some((v, _)) = decode_global(c, GlobalStateType::with(GS_DETAILS)) - { - self.details = strict_val_to_string(&v); - } - } - - fn absorb_asset_spec(&mut self, types: &TypeSystem, spec: &StrictVal, sem_id: SemId) { - let StrictVal::Struct(fields) = spec.skip_wrapper() else { - return; - }; - - let struct_def = match types.get(sem_id) { - Some(Ty::Struct(s)) => Some(s), - _ => None, - }; - - let field_sem_id = |name: &'static str| -> Option { - struct_def - .and_then(|s| s.ty_by_name(&FieldName::from(name))) - .copied() - }; - - if let Some(v) = fields.get(&FieldName::from("ticker")) { - self.ticker = strict_val_to_string(v); - } - - if let Some(v) = fields.get(&FieldName::from("name")) { - self.name = strict_val_to_string(v); - } - - if let Some(v) = fields.get(&FieldName::from("precision")) - && let Some(precision_sem_id) = field_sem_id("precision") - { - self.precision = enum_val_to_ord(types, v, precision_sem_id); + // AssetSpec carries all four fields; CFA keeps them as separate globals. + fn from_globals(globals: &GlobalState) -> Self { + if let Some(spec) = decode_global::(globals, GS_NOMINAL) { + return Self { + name: Some(spec.name.to_string()), + ticker: Some(spec.ticker.to_string()), + precision: Some(spec.precision.decimals()), + details: spec.details.map(|d| d.to_string()), + }; } - - if let Some(v) = fields.get(&FieldName::from("details")) { - self.details = strict_val_to_opt_string(v); + Self { + name: decode_global::(globals, GS_NAME).map(|n| n.to_string()), + ticker: None, + precision: decode_global::(globals, GS_PRECISION).map(Precision::decimals), + details: decode_global::
(globals, GS_DETAILS).map(|d| d.to_string()), } } } -fn decode_global( - c: &Consignment, - type_id: GlobalStateType, -) -> Option<(StrictVal, SemId)> { - let details = c.schema.global_types.get(&type_id)?; - let values = c.genesis.globals.get(&type_id)?; - let first = values.first()?; - let sem_id = details.global_state_schema.sem_id; - - let val = c - .types - .strict_deserialize_type(sem_id, first.as_slice()) - .ok()? - .unbox(); - - Some((val, sem_id)) -} - -fn strict_val_to_string(v: &StrictVal) -> Option { - match v.skip_wrapper() { - StrictVal::String(s) => Some(s.clone()), - StrictVal::Bytes(b) => String::from_utf8(b.to_inner()).ok(), - StrictVal::List(items) if items.is_empty() => Some(String::new()), - StrictVal::List(items) if items.iter().all(|c| matches!(c, StrictVal::Enum(_))) => { - let bytes: Vec = items - .iter() - .filter_map(|c| match c { - StrictVal::Enum(EnumTag::Ord(o)) => Some(*o), - _ => None, - }) - .collect(); - String::from_utf8(bytes).ok() - } - _ => None, - } +// V1 consignments embed no type system, so values decode against the known Rust types. +fn decode_global(globals: &GlobalState, global_type: u16) -> Option { + let value = globals.get(&GlobalStateType::with(global_type))?.first()?; + T::from_strict_serialized(value.to_inner()).ok() } -fn strict_val_to_opt_string(v: &StrictVal) -> Option { - let StrictVal::Union(tag, content) = v.skip_wrapper() else { - return None; - }; - let is_some = - matches!(tag, EnumTag::Name(n) if n.as_str() == "some") || matches!(tag, EnumTag::Ord(1)); - if !is_some { - return None; - } - - strict_val_to_string(content) -} - -fn enum_val_to_ord(types: &TypeSystem, v: &StrictVal, sem_id: SemId) -> Option { - match v.skip_wrapper() { - StrictVal::Enum(EnumTag::Ord(o)) => Some(*o), - StrictVal::Enum(EnumTag::Name(name)) => match types.get(sem_id)? { - Ty::Enum(variants) => variants.tag_by_name(name), - _ => resolve_variant_name(types, sem_id, name), - }, - StrictVal::Number(StrictNum::Uint(n)) => u8::try_from(*n).ok(), - _ => None, - } -} - -fn resolve_variant_name(types: &TypeSystem, sem_id: SemId, name: &VariantName) -> Option { - match types.get(sem_id)? { - Ty::Tuple(fields) if fields.len() == 1 => { - let inner = fields.iter().next()?; - resolve_variant_name(types, *inner, name) - } - Ty::Enum(variants) => variants.tag_by_name(name), - _ => None, - } +fn global_entries(globals: &GlobalState) -> Vec { + globals + .iter() + .flat_map(|(ty, values)| { + values.iter().map(move |value| GlobalEntry { + global_type: ty.to_inner(), + value_hex: to_hex(value.as_slice()), + }) + }) + .collect() } fn transition_info(kt: &KnownTransition) -> TransitionInfo { - let t: &Transition = &kt.transition; + let t = &kt.transition; TransitionInfo { op_id: kt.opid.to_string(), transition_type: t.transition_type.to_inner(), @@ -321,6 +190,7 @@ fn transition_info(kt: &KnownTransition) -> TransitionInfo { value_hex: to_hex(value.as_ref()), }) .collect(), + globals: global_entries(&t.globals), } } @@ -351,19 +221,23 @@ fn fungible_allocations(asgs: &Assignments) -> Vec(a: &Assign) -> FungibleEntry { - let amount: u64 = (*a.as_revealed_state().as_inner()).into(); - let seal = match a { - Assign::Revealed { seal, .. } => SealInfo::Revealed { +fn fungible_entry(a: &Assign) -> FungibleEntry { + FungibleEntry { + amount: (*a.state.as_inner()).into(), + seal: seal_info(&a.seal), + } +} + +fn seal_info(seal: &BuilderSeal) -> SealInfo { + match seal { + BuilderSeal::Revealed(seal) => SealInfo::Revealed { txid: seal.txid().map(|t| t.to_string()), vout: seal.vout().to_u32(), }, - Assign::ConfidentialSeal { seal, .. } => SealInfo::Confidential { - secret_seal: seal.to_string(), + BuilderSeal::Concealed(secret) => SealInfo::Confidential { + secret_seal: secret.to_string(), }, - }; - - FungibleEntry { amount, seal } + } } #[cfg(test)] @@ -371,9 +245,27 @@ mod tests { use super::*; #[test] - fn signed_precision_does_not_crash_the_parser() { - let types = TypeSystem::default(); - let v = StrictVal::num(-1i8); - assert_eq!(enum_val_to_ord(&types, &v, SemId::default()), None); + fn rejects_bytes_without_rgb_header() { + let err = parse(b"nope").unwrap_err(); + assert!(err.to_string().contains("missing RGB file header"), "{err}"); + } + + #[test] + fn rejects_schema_definition_files() { + let err = parse(b"RGB\x00SD2\x00").unwrap_err(); + assert!( + err.to_string().contains("unsupported container type"), + "{err}" + ); + } + + #[test] + fn rejects_truncated_body() { + assert!(parse(b"RGB\x00TFR\x01").is_err()); + } + + #[test] + fn hex_is_lowercase_without_prefix() { + assert_eq!(to_hex(&[0x00, 0xab, 0x10]), "00ab10"); } } diff --git a/testdata/bfa-contract.rgb b/testdata/bfa-contract.rgb new file mode 100644 index 0000000..500dbff Binary files /dev/null and b/testdata/bfa-contract.rgb differ diff --git a/testdata/bfa-transfer-synthetic.rgb b/testdata/bfa-transfer-synthetic.rgb new file mode 100644 index 0000000..9543738 Binary files /dev/null and b/testdata/bfa-transfer-synthetic.rgb differ diff --git a/testdata/cfa-contract.rgb b/testdata/cfa-contract.rgb new file mode 100644 index 0000000..f2dc827 Binary files /dev/null and b/testdata/cfa-contract.rgb differ diff --git a/testdata/ifa-contract.rgb b/testdata/ifa-contract.rgb index f072de9..9ff4d5a 100644 Binary files a/testdata/ifa-contract.rgb and b/testdata/ifa-contract.rgb differ diff --git a/testdata/legacy-v0-bfa-burn.rgb b/testdata/legacy-v0-bfa-burn.rgb new file mode 100644 index 0000000..4803556 Binary files /dev/null and b/testdata/legacy-v0-bfa-burn.rgb differ diff --git a/tests/fixtures.rs b/tests/fixtures.rs index c7ffac8..2873244 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -1,11 +1,26 @@ -//! End-to-end tests over real consignment files in `testdata/`. +//! End-to-end tests over V1 consignment files. +//! +//! The contracts in `testdata/` are the rgb-schemas 0.11.2-rc.1 examples. No +//! release-produced transfer exists yet, so `synthetic` builds one with the +//! rc.1 types and writes it the way rgb-lib does. +mod synthetic; + +use std::collections::BTreeSet; use std::ffi::{CStr, c_char}; +use std::path::PathBuf; use std::ptr; -use rgb_consignment::{ConsignmentInfo, ConsignmentKind, SealInfo, parse}; +use rgb_consignment::{ + ConsignmentInfo, ConsignmentKind, ContractInfo, GlobalEntry, MetaEntry, SealInfo, TransferInfo, + TransitionInfo, parse, +}; +const BFA_CONTRACT: &[u8] = include_bytes!("../testdata/bfa-contract.rgb"); const IFA_CONTRACT: &[u8] = include_bytes!("../testdata/ifa-contract.rgb"); +const CFA_CONTRACT: &[u8] = include_bytes!("../testdata/cfa-contract.rgb"); +const LEGACY_V0_BURN: &[u8] = include_bytes!("../testdata/legacy-v0-bfa-burn.rgb"); +const SYNTHETIC_TRANSFER: &str = "testdata/bfa-transfer-synthetic.rgb"; unsafe extern "C" { fn rgb_consignment_parse(data: *const u8, len: usize, err_out: *mut *mut c_char) @@ -13,33 +28,77 @@ unsafe extern "C" { 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); +fn contract(bytes: &[u8]) -> ContractInfo { + match parse(bytes).expect("fixture must parse") { + ConsignmentInfo::Contract(c) => c, + other => panic!("expected contract, got {:?}", other.kind()), + } +} - let ConsignmentInfo::Contract(c) = info else { - panic!("expected contract"); - }; +fn transfer(bytes: &[u8]) -> TransferInfo { + match parse(bytes).expect("transfer must parse") { + ConsignmentInfo::Transfer(t) => t, + other => panic!("expected transfer, got {:?}", other.kind()), + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn global(global_type: u16, value: &[u8]) -> GlobalEntry { + GlobalEntry { + global_type, + value_hex: hex(value), + } +} + +#[test] +fn bfa_contract_parses() { + let c = contract(BFA_CONTRACT); + assert_eq!(c.version, 1); let g = &c.genesis; assert_eq!( g.contract_id, - "rgb:jpyfP_3m-zroPnJm-2J9qFHO-7ZjimUC-yH4A96u-oHthc64" + "rgb:UiDQRpwu-~dsy7ZH-raGXsnJ-UNiT13f-WbArf5m-jPeb8xU" ); + assert_eq!( + g.schema_id, + "rgb:sch:Ryl5eQZ3IHrU05e3AWdQZJR4FB5GvxZPy96cwhJiApc#amazon-norway-harris" + ); + assert_eq!(c.schema_id, g.schema_id); 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); + // Mint rights are declarative: genesis allocates no asset. + assert!(g.fungible_allocations.is_empty()); + assert_eq!(g.assignment_count, 1); + assert!(c.terminals.is_empty() && c.witnesses.is_empty()); +} +#[test] +fn bfa_genesis_exposes_bridge_location() { + let g = contract(BFA_CONTRACT).genesis; + // BridgeLocation::Evm: union tag 0, chain id 1 (u64 LE), 20-byte address. + let location = format!("00{}{}", hex(&1u64.to_le_bytes()), "deadbeef".repeat(5)); + let entry = g.globals.iter().find(|e| e.global_type == 3008); + assert_eq!(entry.map(|e| e.value_hex.as_str()), Some(location.as_str())); + // The count is of global types; globals has one entry per value. + let types: BTreeSet = g.globals.iter().map(|e| e.global_type).collect(); + assert_eq!(types.len() as u32, g.global_state_count); +} + +#[test] +fn ifa_contract_exposes_genesis_allocations() { + let g = contract(IFA_CONTRACT).genesis; + assert_eq!(g.ticker.as_deref(), Some("TEST")); 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); + assert_eq!( + (a.assignment_type, a.total, a.entries.len()), + (4000, 100_000, 1) + ); match &a.entries[0].seal { SealInfo::Revealed { txid, vout } => { assert_eq!( @@ -50,27 +109,26 @@ fn ifa_contract_parses() { } other => panic!("unexpected seal {other:?}"), } - assert_eq!(g.fungible_allocations[1].assignment_type, 4010); - assert_eq!(g.fungible_allocations[1].total, 50_000); + let inflation = &g.fungible_allocations[1]; + assert_eq!((inflation.assignment_type, inflation.total), (4010, 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 cfa_contract_decodes_scalar_asset_globals() { + let g = contract(CFA_CONTRACT).genesis; + assert_eq!(g.name.as_deref(), Some("Test asset")); + assert_eq!(g.ticker, None); + assert_eq!(g.precision, Some(8)); } #[test] -fn ifa_contract_via_c_abi() { +fn 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() - ); + unsafe { rgb_consignment_parse(BFA_CONTRACT.as_ptr(), BFA_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(); @@ -79,11 +137,182 @@ fn ifa_contract_via_c_abi() { // 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}"); + assert!(text.contains(r#""global_type":3008"#), "{text}"); } #[test] fn truncated_fixture_is_rejected() { - let cut = &IFA_CONTRACT[..IFA_CONTRACT.len() / 2]; - assert!(parse(cut).is_err()); + assert!(parse(&BFA_CONTRACT[..BFA_CONTRACT.len() / 2]).is_err()); +} + +#[test] +fn container_type_must_match_transfer_flag() { + let mut bytes = BFA_CONTRACT.to_vec(); + bytes[4..7].copy_from_slice(b"VTF"); + let err = parse(&bytes).unwrap_err(); + assert!(err.to_string().contains("transfer flag"), "{err}"); +} + +// The hard cutover relies on this: pre-cutover BFA consignments must not parse. +#[test] +fn legacy_v0_transfer_is_rejected() { + assert_eq!(&LEGACY_V0_BURN[..8], b"RGB\x00TFR\x00"); + let err = parse(LEGACY_V0_BURN).unwrap_err(); + assert!(err.to_string().contains("ConsignmentVer"), "{err}"); +} + +#[test] +fn validated_transfer_container_parses_like_plain_one() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + let plain = synthetic::file_bytes(&built.transfer); + let validated = synthetic::validated_file_bytes(&built.transfer); + assert_eq!(&validated[..7], b"RGB\x00VTF"); + assert_eq!( + serde_json::to_value(transfer(&validated)).unwrap(), + serde_json::to_value(transfer(&plain)).unwrap() + ); +} + +#[test] +fn plain_contract_container_parses_like_validated_one() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + let bytes = synthetic::file_bytes(&built.transfer.into_contract()); + assert_eq!(&bytes[..7], b"RGB\x00CON"); + assert_eq!( + contract(&bytes).genesis.contract_id, + contract(BFA_CONTRACT).genesis.contract_id + ); +} + +fn mint_of(t: &TransferInfo) -> &TransitionInfo { + &t.witnesses[0].transitions[0] +} + +fn burn_of(t: &TransferInfo) -> &TransitionInfo { + &t.witnesses[1].transitions[0] +} + +#[test] +fn synthetic_transfer_lists_both_witnesses() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + let t = transfer(&synthetic::file_bytes(&built.transfer)); + assert_eq!(t.version, 1); + assert_eq!(t.bundle_count, 2); + assert_eq!( + t.genesis.contract_id, + contract(BFA_CONTRACT).genesis.contract_id + ); + let txids: Vec = built + .transfer + .bundles + .iter() + .map(|wb| wb.witness_id().to_string()) + .collect(); + let parsed: Vec<&str> = t.witnesses.iter().map(|w| w.txid.as_str()).collect(); + assert_eq!(parsed, txids); + assert_eq!(mint_of(&t).op_id, built.mint.to_string()); + assert_eq!(burn_of(&t).op_id, built.burn.to_string()); +} + +#[test] +fn synthetic_mint_exposes_issued_supply_and_after_block() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + let t = transfer(&synthetic::file_bytes(&built.transfer)); + let mint = mint_of(&t); + assert_eq!(mint.transition_type, synthetic::TS_MINT); + assert_eq!( + mint.globals, + vec![global( + synthetic::GS_ISSUED_SUPPLY, + &synthetic::MINTED.to_le_bytes() + )] + ); + assert_eq!( + mint.metadata, + vec![MetaEntry { + meta_type: synthetic::MS_AFTER_BLOCK, + value_hex: hex(&synthetic::AFTER_BLOCK.to_le_bytes()), + }] + ); + let allocation = &mint.fungible_allocations[0]; + assert_eq!( + (allocation.assignment_type, allocation.total), + (synthetic::OS_ASSET, synthetic::MINTED) + ); + assert!(matches!( + allocation.entries[0].seal, + SealInfo::Revealed { + txid: None, + vout: 0 + } + )); +} + +#[test] +fn synthetic_burn_exposes_burned_asset_and_reason() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + let t = transfer(&synthetic::file_bytes(&built.transfer)); + let burn = burn_of(&t); + assert_eq!(burn.transition_type, synthetic::TS_BURN); + assert!(burn.metadata.is_empty()); + // Burn reason is a bare 32-byte array: no length prefix. + assert_eq!( + burn.globals, + vec![ + global(synthetic::GS_BURNED_ASSET, &synthetic::BURNED.to_le_bytes()), + global(synthetic::GS_BURN_REASON, &synthetic::burn_reason()), + ] + ); + let change = &burn.fungible_allocations[0].entries[0]; + assert_eq!(change.amount, synthetic::MINTED - synthetic::BURNED); + let SealInfo::Confidential { secret_seal } = &change.seal else { + panic!("change seal should be concealed: {:?}", change.seal); + }; + assert_eq!(*secret_seal, built.change_seal.to_secret_seal().to_string()); +} + +#[test] +fn synthetic_terminals_list_concealed_and_revealed_seals() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + let burn_bundle = built.transfer.bundles[1].bundle.bundle_id().to_string(); + let t = transfer(&synthetic::file_bytes(&built.transfer)); + assert_eq!(t.terminals.len(), 1); + assert_eq!(t.terminals[0].bundle_id, burn_bundle); + let [concealed, revealed] = t.terminals[0].seals.as_slice() else { + panic!("expected two terminal seals: {:?}", t.terminals[0].seals); + }; + assert!( + matches!(concealed, SealInfo::Confidential { secret_seal } if *secret_seal == built.change_seal.to_secret_seal().to_string()) + ); + assert!(matches!( + revealed, + SealInfo::Revealed { + txid: None, + vout: 2 + } + )); + // A witness-vout terminal resolves through the witness of the same bundle. + let witness = t.witnesses.iter().find(|w| w.bundle_id == burn_bundle); + assert_eq!( + witness.map(|w| w.txid.clone()), + Some(built.transfer.bundles[1].witness_id().to_string()) + ); +} + +fn synthetic_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SYNTHETIC_TRANSFER) +} + +#[test] +fn committed_synthetic_transfer_matches_builder() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + let committed = std::fs::read(synthetic_path()).expect("run regenerate_synthetic_transfer"); + assert_eq!(committed, synthetic::file_bytes(&built.transfer)); +} + +#[test] +#[ignore = "writes testdata/bfa-transfer-synthetic.rgb for the Go tests; commit the result"] +fn regenerate_synthetic_transfer() { + let built = synthetic::build(synthetic::bfa_genesis(BFA_CONTRACT)); + std::fs::write(synthetic_path(), synthetic::file_bytes(&built.transfer)).unwrap(); } diff --git a/tests/synthetic/mod.rs b/tests/synthetic/mod.rs new file mode 100644 index 0000000..ebec536 --- /dev/null +++ b/tests/synthetic/mod.rs @@ -0,0 +1,215 @@ +//! Builds a BFA mint + burn transfer with the RGB 0.11.2-rc.1 types. +//! +//! Stand-in until release-produced transfers exist: the operations are well +//! formed but the anchors are dummies, so it would never validate. + +use std::collections::BTreeMap; + +use amplify::Bytes32; +use amplify::confinement::{ + Confined, LargeVec, NonEmptyOrdMap, NonEmptyOrdSet, NonEmptyVec, SmallBlob, SmallOrdMap, U16, + U32, +}; +use rgbstd::assignments::AssignVec; +use rgbstd::bitcoin::Transaction; +use rgbstd::bitcoin::absolute::LockTime; +use rgbstd::bitcoin::transaction::Version; +use rgbstd::containers::{ + Consignment, ConsignmentVer, FileContent, TerminalSeals, Transfer, WitnessBundle, +}; +use rgbstd::rgbcore::validation::Status; +use rgbstd::stl::{BlockNumber, BurnReason}; +use rgbstd::{ + Amount, Assign, AssignmentType, Assignments, BuilderSeal, Genesis, GlobalState, + GlobalStateType, GraphSeal, Inputs, KnownTransition, MetaType, MetaValue, Metadata, OpId, + Operation, Opout, RevealedData, RevealedValue, Transition, TransitionBundle, TransitionType, + TypedAssigns, +}; +use strict_types::encoding::{StreamReader, StrictDecode, StrictDumb, StrictSerialize}; + +pub const TS_MINT: u16 = 8013; +pub const TS_BURN: u16 = 8010; +pub const OS_ASSET: u16 = 4000; +pub const OS_MINT: u16 = 4013; +pub const GS_ISSUED_SUPPLY: u16 = 2010; +pub const GS_BURNED_ASSET: u16 = 2015; +pub const GS_BURN_REASON: u16 = 3009; +pub const MS_AFTER_BLOCK: u16 = 1003; + +pub const MINTED: u64 = 1000; +pub const BURNED: u64 = 400; +pub const AFTER_BLOCK: u64 = 42; + +/// 12 zero bytes + a 20-byte EVM recipient, the bridge's burn reason layout. +pub fn burn_reason() -> [u8; 32] { + let mut reason = [0xab; 32]; + reason[..12].fill(0); + reason +} + +/// The pieces a test asserts against, next to the transfer built from them. +pub struct Built { + pub transfer: Transfer, + pub mint: OpId, + pub burn: OpId, + pub change_seal: GraphSeal, +} + +/// Genesis of the rgb-schemas BFA example contract (a `VCO` container). +pub fn bfa_genesis(contract_file: &[u8]) -> Genesis { + let body = &contract_file[7..]; + Consignment::::strict_read(StreamReader::new::(body)) + .expect("BFA example contract decodes") + .genesis +} + +pub fn build(genesis: Genesis) -> Built { + let change_seal = GraphSeal::with_blinded_vout(1, 7); + let terminal_seal = GraphSeal::with_blinded_vout(2, 9); + + let mint = mint_transition(&genesis); + let mint_id = mint.id(); + let burn = burn_transition(&genesis, mint_id, change_seal); + let burn_id = burn.id(); + + let mint_bundle = witness_bundle(1, mint); + let burn_bundle = witness_bundle(2, burn); + let seals = vec![ + BuilderSeal::Concealed(change_seal.to_secret_seal()), + BuilderSeal::Revealed(terminal_seal), + ]; + let terminals = BTreeMap::from([( + burn_bundle.bundle.bundle_id(), + TerminalSeals::from(NonEmptyVec::try_from(seals).unwrap()), + )]); + + let transfer = Transfer { + version: ConsignmentVer::V1, + transfer: true, + terminals: SmallOrdMap::try_from(terminals).unwrap(), + genesis, + bundles: LargeVec::try_from(vec![mint_bundle, burn_bundle]).unwrap(), + }; + Built { + transfer, + mint: mint_id, + burn: burn_id, + change_seal, + } +} + +/// The container written as rgb-lib writes `consignment_out` (`RGB\0TFR` for a transfer). +pub fn file_bytes(container: &impl FileContent) -> Vec { + let mut bytes = Vec::new(); + container.save(&mut bytes).unwrap(); + bytes +} + +/// The transfer written as rgb-lib keeps a validated copy: `RGB\0VTF`, the same +/// body, then the validation status as JSON (rgb-ops `ValidConsignment::save_file`). +pub fn validated_file_bytes(transfer: &Transfer) -> Vec { + let mut bytes = file_bytes(transfer); + bytes[4..7].copy_from_slice(b"VTF"); + serde_json::to_writer(&mut bytes, &Status::default()).unwrap(); + bytes +} + +fn mint_transition(genesis: &Genesis) -> Transition { + let mut metadata = Metadata::default(); + metadata + .add_value( + MetaType::with(MS_AFTER_BLOCK), + MetaValue::from(blob(&BlockNumber::from(AFTER_BLOCK))), + ) + .unwrap(); + let mut globals = GlobalState::default(); + add_global(&mut globals, GS_ISSUED_SUPPLY, &Amount::from(MINTED)); + // A witness-vout seal: no txid, it points into the witness tx. + let seal = BuilderSeal::Revealed(GraphSeal::with_blinded_vout(0, 3)); + transition( + genesis, + TS_MINT, + metadata, + globals, + Opout::new(genesis.id(), AssignmentType::with(OS_MINT), 0), + seal, + MINTED, + ) +} + +fn burn_transition(genesis: &Genesis, mint: OpId, change: GraphSeal) -> Transition { + let mut globals = GlobalState::default(); + add_global(&mut globals, GS_BURNED_ASSET, &Amount::from(BURNED)); + add_global( + &mut globals, + GS_BURN_REASON, + &BurnReason::from(Bytes32::from_byte_array(burn_reason())), + ); + let seal = BuilderSeal::Concealed(change.to_secret_seal()); + let input = Opout::new(mint, AssignmentType::with(OS_ASSET), 0); + transition( + genesis, + TS_BURN, + Metadata::default(), + globals, + input, + seal, + MINTED - BURNED, + ) +} + +fn transition( + genesis: &Genesis, + transition_type: u16, + metadata: Metadata, + globals: GlobalState, + input: Opout, + seal: BuilderSeal, + amount: u64, +) -> Transition { + let asset = Assign::with(seal, RevealedValue::from(amount)); + let typed = TypedAssigns::Fungible(AssignVec::with(NonEmptyVec::with(asset))); + Transition { + ffv: Default::default(), + contract_id: genesis.contract_id(), + nonce: u64::MAX, + transition_type: TransitionType::with(transition_type), + metadata, + globals, + inputs: Inputs::from(NonEmptyOrdSet::with(input)), + assignments: Assignments::from( + Confined::try_from(BTreeMap::from([(AssignmentType::with(OS_ASSET), typed)])).unwrap(), + ), + signature: None, + } +} + +fn witness_bundle(lock_time: u32, transition: Transition) -> WitnessBundle { + let opid = transition.id(); + let input = (&transition.inputs).into_iter().next().unwrap(); + let mut wb = WitnessBundle::strict_dumb(); + wb.tx = Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: vec![], + output: vec![], + }; + wb.bundle = TransitionBundle { + input_map: NonEmptyOrdMap::with_key_value(input, opid), + known_transitions: NonEmptyVec::with(KnownTransition::new(opid, transition)), + }; + wb +} + +fn add_global(globals: &mut GlobalState, global_type: u16, value: &impl StrictSerialize) { + globals + .add_state( + GlobalStateType::with(global_type), + RevealedData::new(blob(value)), + ) + .unwrap(); +} + +fn blob(value: &impl StrictSerialize) -> SmallBlob { + value.to_strict_serialized::().unwrap() +}