Skip to content
unsafePtrPublic

About

C# implementation of the Binary Canonical Serialization (BCS) format

Resources

Stars

7 stars

Watchers

0 watching

Forks

Repository files navigation

BcsSharp — Binary Canonical Serialization for C#

ci NuGet

High-performance C# implementation of Binary Canonical Serialization (BCS), byte-for-byte compatible with Rust's bcs crate and the Sui blockchain ecosystem.

Highlights

  • Wire-compatible with Rust BCS, verified against bytes generated by the Rust bcs crate (see Rust compatibility).
  • Serializing into an IBufferWriter<byte> allocates nothing: the output buffer is the caller's, and typed per-field dispatch never boxes.
  • Deserializing into a value type allocates nothing: struct fields are written through compiled ref T setters, so the instance is never boxed.
  • C# 15 union types model Rust tagged enums, and Option<T> for reference types.

Requirements

BcsSharp targets net11.0, so consumers need .NET 11 or later. System.Runtime.CompilerServices.UnionAttribute and IUnion, which the union keyword lowers to, ship in the .NET 11 BCL from RC1 on; earlier previews lack them.

Building needs the .NET 11 SDK RC1 or later, for the C# 15 compiler that understands union. C# 15 is the net11.0 default, so no LangVersion is needed.

Type mapping

Rust BCS C#
u8/u16/u32/u64/u128 byte, ushort, uint, ulong, UInt128
i8/i16/i32/i64/i128 sbyte, short, int, long, Int128
bool, String bool, string (non-null — see Options)
Vec<T> List<T> (non-null; not T[])
BTreeMap<K, V> Dictionary<K, V> (non-null; sorted by serialized key bytes)
Option<T> for value-type T T? (e.g. uint?, bool?)
Option<T> for reference-type T Option<T> from BcsSharp.Core.Unions
enum Foo { A, B, C } (unit variants) enum Foo { A, B, C } (CLR enum)
enum Foo { A(u32), B(String), Common, ... } union Foo(A, B, Common, ...) (C# 15 union)
(T1, T2, T3) (T1, T2, T3) (ValueTuple up to arity 4)
struct Foo { a: T1, b: T2 } [BcsStruct] class Foo { [BcsField(0)] public T1 A; [BcsField(1)] public T2 B; }

Quick start

using BcsSharp.Core;
using BcsSharp.Core.Attributes;
using BcsSharp.Core.Unions;
using System.Buffers;

[BcsStruct]
public class User
{
    [BcsField(0)] public ulong Id { get; set; }
    [BcsField(1)] public string Name { get; set; } = "";
    [BcsField(2)] public Option<Address> Address { get; set; } = None.Instance;
}

[BcsStruct]
public class Address
{
    [BcsField(0)] public string City { get; set; } = "";
}

var user = new User { Id = 12345, Name = "Alice", Address = new Address { City = "Plovdiv" } };

// Allocating overload — returns a new byte[]
byte[] bytes = BcsSerializer.Serialize(user);
User back = BcsSerializer.Deserialize<User>(bytes);

// Zero-allocation overload — writes into caller-owned buffer
var bw = new ArrayBufferWriter<byte>(64);
BcsSerializer.Serialize(bw, user);
ReadOnlySpan<byte> output = bw.WrittenSpan;

Options

// Value-type optional → Nullable<T> (zero alloc, value-type semantics)
[BcsField(0)] public uint? Age { get; set; }
[BcsField(1)] public bool? IsActive { get; set; }

// Reference-type optional → Option<T> union (zero alloc when T is class)
[BcsField(2)] public Option<Address> HomeAddress { get; set; } = None.Instance;

// Direct assignment uses C# 15's implicit conversion
Option<Address> opt = new Address { City = "Sofia" };   // Some
Option<Address> none = None.Instance;                   // None (singleton)

// Pattern matching
string city = opt switch
{
    None _ => "(unknown)",
    Address a => a.City,
    _ => throw new InvalidOperationException(),
};

On the wire, None is 0x00 and Some is 0x01 followed by the payload, exactly as Rust writes Option<T>.

Both directions are allocation-free for None: serializing writes one byte, and deserializing hands back None.Instance itself rather than a fresh instance. Some allocates exactly the payload, with nothing wrapping it.

Reference types are never optional by annotation

BCS has no null — Option<T> is the optional form for a string, a vector and a map alike. C# nullable annotations are erased at runtime, so string and string? are the same type and the library cannot tell them apart. Serializing a null string, List<T> or Dictionary<K, V> therefore throws ArgumentNullException rather than silently encoding it as empty:

[BcsField(0)] public string Name { get; set; } = "";                          // required
[BcsField(1)] public Option<string> Email { get; set; } = None.Instance;      // optional
[BcsField(2)] public List<Tag> Tags { get; set; } = [];                       // required, may be empty
[BcsField(3)] public Option<List<Tag>> Aliases { get; set; } = None.Instance; // optional

string? Email is not a shorthand for Option<string>, nor is List<Tag>? one for Option<List<Tag>>. A string? holding "hi" encodes as 02 68 69, missing the 0x01 discriminant that Rust's Option<String> writes — a silent wire mismatch. Only the absent case coincides (0x00 for an empty string, an empty vector and Option.None alike), which is exactly what makes the mistake hard to spot.

Deserialization never returns null: a zero-length string decodes to "" and a zero-length vector to an empty List<T>.

Tagged unions (Rust enums with payloads)

[BcsStruct] public sealed record class Uncommon { [BcsField(0)] public uint Value { get; set; } }
[BcsStruct] public sealed record class Rare    { [BcsField(0)] public List<uint> Values { get; set; } = []; }
[BcsStruct] public sealed record class Epic    { [BcsField(0)] public string Name { get; set; } = ""; }
public sealed record class Common;   // unit variant — no fields

public union Rarity(Common, Uncommon, Rare, Epic);

// Serialize / deserialize
Rarity r = new Epic { Name = "Sword" };
byte[] bytes = BcsSerializer.Serialize(r);
// → [0x03, 0x05, 'S', 'w', 'o', 'r', 'd']  (variant 3 + ULEB-prefixed UTF-8)

The variant index is the declaration order of the cases (Common = 0, Uncommon = 1, …), so reordering them breaks the wire.

Prefer class case payloads. A union stores its payload in an object slot, so a struct case is boxed once when the union value is constructed — correct on the wire, but an allocation on a path that is otherwise free.

[BcsEnum] interfaces

The older attribute-based path is still supported. It suits variants that need a shared interface for runtime polymorphism beyond BCS:

[BcsEnum] public interface IRarity {}
[BcsEnumVariant(0)] public sealed class CommonLegacy : IRarity {}
[BcsEnumVariant(1)] public sealed class UncommonLegacy : IRarity { [BcsEnumData] public uint Value { get; set; } }

Same wire output as union. Prefer union for new code.

Every variant needs an explicit, unique index and must be declared in the marker's assembly; other assemblies are never searched.

CLR enums (Rust's unit-only enums)

public enum AssetType { Weapon = 100, Armor, Consumable, Material, Currency }

On the wire a member is the ULEB128 of its declaration index, not of its value. AssetType.Material, declared fourth, serializes as 0x03, not as its value 103 (0x67). Values need not ascend: in enum Level { High = 30, Low = 10 }, High is 0x00. Two members with the same value are rejected, because the wire has one index per member and the alias could not round-trip.

Custom formatters

Per-type via attribute

[BcsFormatter(typeof(SuiAddressFormatter))]
public readonly struct SuiAddress(byte firstByte) { /* ... */ }

public sealed class SuiAddressFormatter : ByteArrayFormatter<SuiAddress>
{
    public static readonly SuiAddressFormatter Instance = new();
    public override int GetLength() => 32;
    public override ReadOnlySpan<byte> GetBytes(SuiAddress v) => v.Bytes;
    public override SuiAddress GetFromBytes(ReadOnlySpan<byte> bytes) => new(bytes);
}

ByteArrayFormatter<T> is the recommended base for fixed-length byte-array-backed types. All three abstract members are span-based, so deserializing through it allocates nothing.

Manual registration (no source access to the target type)

CustomFormatterResolver.Instance.Register<ThirdPartyType>(new ThirdPartyFormatter());
BcsSerializer.ClearFormatterCache();   // only needed if registering late

CustomFormatterResolver sits first in the resolver chain, so it overrides any built-in formatter for the same type.

256-bit integers (Sui u256)

BCS has no 256-bit type: the format stops at 128 bits, as does serde's data model, which the reference Rust bcs crate is built on. Sui's Move u256 gets its encoding by delegating to a fixed [u8; 32] array, so it is 32 bare little-endian bytes with no length prefix. A Vec<u8> of the same bytes would be 33: ULEB 0x20 followed by the data. That framing is an application-level convention rather than part of BCS, so the library does not ship it; supply it with a formatter:

public sealed class SuiUInt256Formatter : IBcsFormatter<UInt256>   // Nethermind.Int256
{
    public static readonly SuiUInt256Formatter Instance = new();

    public void Serialize(ref BcsWriter writer, UInt256 value)
    {
        Span<byte> littleEndian = stackalloc byte[32];
        value.ToLittleEndian(littleEndian);
        writer.WriteBytes(littleEndian);          // no ULEB prefix — fixed-size array
    }

    public UInt256 Deserialize(ref BcsReader reader)
        => new(reader.ReadBytesAsSpan(32), isBigEndian: false);   // zero-copy
}

CustomFormatterResolver.Instance.Register(SuiUInt256Formatter.Instance);

UInt256?, List<UInt256> and [BcsStruct] fields of that type then resolve through the same chain with no further wiring. A chain that encodes 256-bit values differently (big-endian, or length-prefixed) is a one-line change to the same formatter. See BcsSharp.Tests/Extensibility/ for the worked example, verified byte-for-byte against Rust.

Resolver chain (highest priority first)

  1. CustomFormatterResolver — manual Register<T>(...)
  2. AttributeFormatterResolver — [BcsFormatter] attribute
  3. StandardResolver — primitives, List<T>, Dictionary<K,V>, ValueTuple<,>/<,,>/<,,,>, Nullable<T>
  4. UnionResolver — C# 15 union types
  5. VariantEnumResolver — [BcsEnum] tagged unions
  6. SimpleEnumResolver — CLR enums
  7. ObjectResolver — [BcsStruct] classes/structs

Allocation profile

Measured per operation on .NET 11 Preview 3, for a [BcsStruct] with two uint fields:

Path Bytes per op
Serialize<T>(T) → byte[] 32 B (just the output array)
Serialize<T>(IBufferWriter<byte>, T) 0 B
Deserialize<T>(bytes) value-type T 0 B
Deserialize<T>(bytes) class T 24 B (the class instance itself; intrinsic)

For maximum throughput, use the IBufferWriter<byte> overload and pool the buffer writer.

The byte[]-returning overload serializes into a thread-static 64 KB scratch buffer, created on first use and reused across calls, so it allocates only the final output. A re-entrant Serialize from inside a custom formatter falls back to ArrayPool<byte>.Shared.

Low-level reader / writer API

// Write
var bw = new ArrayBufferWriter<byte>();
var writer = new BcsWriter(bw);
writer.Write(42u);                  // u32 LE
writer.WriteString("Alice");        // ULEB length + UTF-8
writer.WriteBool(true);
writer.WriteULEB(1000u);
writer.Flush();                     // commits the bytes to bw

// Read
var reader = new BcsReader(bw.WrittenMemory);
var n = reader.Read32();            // 42
var name = reader.ReadString();     // "Alice"
var ok = reader.ReadBool();         // true
var u = reader.ReadULEB32();        // 1000

// Zero-copy bytes view
ReadOnlySpan<byte> raw = reader.ReadBytesAsSpan(8);

BcsWriter is a ref struct over an IBufferWriter<byte> or a caller-owned Span<byte>. Over an IBufferWriter<byte> it writes into the span it was last handed and commits only on Flush(), so call it before reading the buffer writer; the BcsSerializer overloads flush for you. Pass it by ref: a copy tracks its own uncommitted bytes, so writes through the copy are lost. BcsReader is a ref struct over ReadOnlyMemory<byte>.

Rules worth knowing

  • Vectors are List<T>, mirroring Rust's Vec<T>; plain T[] arrays are rejected.
  • [BcsField] numbers are the wire order and must run 0, 1, 2, …. A duplicate or a gap is rejected when the type's formatter is built, and renumbering fields breaks the wire.
  • Variant indices are part of the wire format. A union numbers its cases in declaration order, and a [BcsEnum] variant takes its index from [BcsEnumVariant].
  • Recursive types resolve. A struct or union may reference itself, directly or through Option<T>, List<T>, Dictionary<K,V> or a tuple — Sui's TypeTag is the typical case. Deserialization bounds the nesting at MaxContainerDepth.
  • Map keys are sorted by their serialized bytes, not by IComparable, matching the BCS spec and Rust's bcs::ser::MapSerializer, which re-sorts after BTreeMap iteration. For integer keys the two orders differ: as a uint, 256 (00 01 00 00) sorts before 1 (01 00 00 00).
  • Null is rejected. BCS has no null string, vector or map, so a null string, List<T> or Dictionary<K, V> throws ArgumentNullException instead of encoding as empty. Use Option<T> for an optional value — string? and List<T>? are erased at runtime and encode without the Option discriminant.

Building & testing

$env:MSBUILDUSESERVER = '1'
dotnet restore
dotnet build -p:WarningLevel=0 -v:q --no-restore

# Test (xUnit v3 + Microsoft.Testing.Platform)
dotnet run --project BcsSharp.Tests/BcsSharp.Tests.csproj --no-build

# Filter
dotnet run --project BcsSharp.Tests/BcsSharp.Tests.csproj -- -class BcsSharp.Tests.RustBcsCompatibilityTests

Project layout

BcsSharp.slnx
BcsSharp.Core/             # The library — the only project that ships
├── Attributes/            # [BcsStruct], [BcsField], [BcsEnum*], [BcsFormatter]
├── Formatters/            # IBcsFormatter<T> implementations + UnionFormatter, ByteArrayFormatter
├── Resolvers/             # Composite chain entries
├── Unions/                # Option<T>, None
├── BcsSerializer.cs       # Public entry points
├── BcsReader.cs           # ref struct
├── BcsWriter.cs           # ref struct
└── ScratchBufferWriter.cs
BcsSharp.Tests/            # xUnit v3 MTP
└── Fixtures/rust-bcs/     # Rust reference that generates the .bcs parity fixtures
BcsSharp.Benchmarks/       # BenchmarkDotNet harness; results against MessagePack in its README

Rust compatibility

BcsSharp.Tests/Fixtures/rust-bcs generates fixtures with the Rust bcs crate inside Docker, and the parity tests compare against them byte for byte. They are explicit tests, so pass -explicit on to run them.

About

C# implementation of the Binary Canonical Serialization (BCS) format

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages