High-performance C# implementation of Binary Canonical Serialization (BCS), byte-for-byte compatible with Rust's bcs crate and the Sui blockchain ecosystem.
- Wire-compatible with Rust BCS, verified against bytes generated by the Rust
bcscrate (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 Tsetters, so the instance is never boxed. - C# 15
uniontypes model Rust tagged enums, andOption<T>for reference types.
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.
| 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; } |
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;// 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.
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; // optionalstring? 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>.
[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.
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.
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.
[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.
CustomFormatterResolver.Instance.Register<ThirdPartyType>(new ThirdPartyFormatter());
BcsSerializer.ClearFormatterCache(); // only needed if registering lateCustomFormatterResolver sits first in the resolver chain, so it overrides any built-in formatter for the same type.
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.
CustomFormatterResolver— manualRegister<T>(...)AttributeFormatterResolver—[BcsFormatter]attributeStandardResolver— primitives,List<T>,Dictionary<K,V>,ValueTuple<,>/<,,>/<,,,>,Nullable<T>UnionResolver— C# 15uniontypesVariantEnumResolver—[BcsEnum]tagged unionsSimpleEnumResolver— CLR enumsObjectResolver—[BcsStruct]classes/structs
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.
// 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>.
- Vectors are
List<T>, mirroring Rust'sVec<T>; plainT[]arrays are rejected. [BcsField]numbers are the wire order and must run0, 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
unionnumbers 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'sTypeTagis the typical case. Deserialization bounds the nesting atMaxContainerDepth. - Map keys are sorted by their serialized bytes, not by
IComparable, matching the BCS spec and Rust'sbcs::ser::MapSerializer, which re-sorts afterBTreeMapiteration. For integer keys the two orders differ: as auint, 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>orDictionary<K, V>throwsArgumentNullExceptioninstead of encoding as empty. UseOption<T>for an optional value —string?andList<T>?are erased at runtime and encode without theOptiondiscriminant.
$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.RustBcsCompatibilityTestsBcsSharp.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
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.