From 4df47d54bd97ea8693641ea28f7264c4ca517970 Mon Sep 17 00:00:00 2001 From: David Hernando Date: Sun, 1 Mar 2026 21:25:16 +0100 Subject: [PATCH 1/5] wip Signed-off-by: David Hernando --- Casper.Network.SDK.Test/CESParserTest.cs | 233 ++++++++++++++++++++++ Casper.Network.SDK/CES/CESEvent.cs | 89 +++++++++ Casper.Network.SDK/CES/CESParser.cs | 234 +++++++++++++++++++++++ Docs/Articles/CasperEventStandard.md | 188 ++++++++++++++++++ 4 files changed, 744 insertions(+) create mode 100644 Casper.Network.SDK.Test/CESParserTest.cs create mode 100644 Casper.Network.SDK/CES/CESEvent.cs create mode 100644 Casper.Network.SDK/CES/CESParser.cs create mode 100644 Docs/Articles/CasperEventStandard.md diff --git a/Casper.Network.SDK.Test/CESParserTest.cs b/Casper.Network.SDK.Test/CESParserTest.cs new file mode 100644 index 0000000..c3102fb --- /dev/null +++ b/Casper.Network.SDK.Test/CESParserTest.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime; +using Casper.Network.SDK.CES; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + [TestFixture] + public class CESParserTest + { + // Schema binary for one event "Transfer" with two fields: + // amount (CLType.U512 = 0x08) + // sender (CLType.String = 0x0a) + private static readonly string SchemaHex = string.Concat( + "01000000", // 1 event + "08000000", // len("Transfer") = 8 + "5472616e73666572", // "Transfer" + "02000000", // 2 fields + "06000000", // len("amount") = 6 + "616d6f756e74", // "amount" + "08", // CLType.U512 + "06000000", // len("sender") = 6 + "73656e646572", // "sender" + "0a" // CLType.String + ); + + // Event binary for event_Transfer(amount=100, sender="Alice") + // U512(100) serializes as: 0x01 (length byte) + 0x64 (100 in one byte) + // String("Alice") serializes as: 0x05000000 (length) + 0x416c696365 + private static readonly string EventHex = string.Concat( + "0e000000", // len("event_Transfer") = 14 + "6576656e745f5472616e73666572", // "event_Transfer" + "01", // U512 length = 1 byte + "64", // U512 value = 100 (0x64) + "05000000", // len("Alice") = 5 + "416c696365" // "Alice" + ); + + [Test] + public void ParseSchema_SingleEvent_CorrectEventName() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + + Assert.IsTrue(schema.TryGetEventSchema("Transfer", out var eventSchema)); + Assert.AreEqual("Transfer", eventSchema.EventName); + } + + [Test] + public void ParseSchema_SingleEvent_CorrectFieldCount() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + + schema.TryGetEventSchema("Transfer", out var eventSchema); + Assert.AreEqual(2, eventSchema.Fields.Count); + } + + [Test] + public void ParseSchema_SingleEvent_CorrectFieldDefinitions() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + + schema.TryGetEventSchema("Transfer", out var eventSchema); + + Assert.AreEqual("amount", eventSchema.Fields[0].Name); + Assert.AreEqual(CLType.U512, eventSchema.Fields[0].CLTypeInfo.Type); + + Assert.AreEqual("sender", eventSchema.Fields[1].Name); + Assert.AreEqual(CLType.String, eventSchema.Fields[1].CLTypeInfo.Type); + } + + [Test] + public void ParseSchema_UnknownEvent_ReturnsFalse() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + + Assert.IsFalse(schema.TryGetEventSchema("Mint", out _)); + } + + [Test] + public void ParseEvent_CorrectEventName() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.AreEqual("event_Transfer", evt.Name); + } + + [Test] + public void ParseEvent_CorrectFieldCount() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.AreEqual(2, evt.Fields.Count); + } + + [Test] + public void ParseEvent_U512FieldCorrectValue() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + + var amountField = evt["amount"]; + Assert.IsNotNull(amountField); + Assert.AreEqual(CLType.U512, amountField.Value.TypeInfo.Type); + Assert.AreEqual(new BigInteger(100), amountField.Value.ToBigInteger()); + } + + [Test] + public void ParseEvent_StringFieldCorrectValue() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + + var senderField = evt["sender"]; + Assert.IsNotNull(senderField); + Assert.AreEqual(CLType.String, senderField.Value.TypeInfo.Type); + Assert.AreEqual("Alice", senderField.Value.ToString()); + } + + [Test] + public void ParseEvent_IndexerReturnsNullForMissingField() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.IsNull(evt["nonexistent"]); + } + + [Test] + public void ParseEvent_UnknownEventName_ThrowsKeyNotFoundException() + { + var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + + // Build event bytes with an event name ("event_Mint") not present in the schema. + var mintEventHex = string.Concat( + "0a000000", // len("event_Mint") = 10 + "6576656e745f4d696e74" // "event_Mint" + ); + + Assert.Throws( + () => CESParser.ParseEvent(Hex.Decode(mintEventHex), schema)); + } + + [Test] + public void ParseSchema_OptionType_CorrectInnerType() + { + // Schema with one event "Approve" and one Option(PublicKey) field "operator" + // CLType.Option = 0x0d, CLType.PublicKey = 0x16 + var schemaHex = string.Concat( + "01000000", // 1 event + "07000000", // len("Approve") = 7 + "417070726f7665", // "Approve" + "01000000", // 1 field + "08000000", // len("operator") = 8 + "6f70657261746f72", // "operator" + "0d", // CLType.Option + "16" // CLType.PublicKey + ); + + var schema = CESParser.ParseSchema(Hex.Decode(schemaHex)); + + schema.TryGetEventSchema("Approve", out var eventSchema); + Assert.AreEqual("operator", eventSchema.Fields[0].Name); + Assert.AreEqual(CLType.Option, eventSchema.Fields[0].CLTypeInfo.Type); + + var optionType = (CLOptionTypeInfo)eventSchema.Fields[0].CLTypeInfo; + Assert.AreEqual(CLType.PublicKey, optionType.OptionType.Type); + } + + [Test] + public void ParseSchema_MapType_CorrectKeyAndValueTypes() + { + // Schema with one event "Allowances" and one Map(PublicKey, U256) field "data" + // CLType.Map = 0x11, CLType.PublicKey = 0x16, CLType.U256 = 0x07 + var schemaHex = string.Concat( + "01000000", // 1 event + "0a000000", // len("Allowances") = 10 + "416c6c6f77616e636573", // "Allowances" + "01000000", // 1 field + "04000000", // len("data") = 4 + "64617461", // "data" + "11", // CLType.Map + "16", // key: CLType.PublicKey + "07" // value: CLType.U256 + ); + + var schema = CESParser.ParseSchema(Hex.Decode(schemaHex)); + + schema.TryGetEventSchema("Allowances", out var eventSchema); + Assert.AreEqual("data", eventSchema.Fields[0].Name); + Assert.AreEqual(CLType.Map, eventSchema.Fields[0].CLTypeInfo.Type); + + var mapType = (CLMapTypeInfo)eventSchema.Fields[0].CLTypeInfo; + Assert.AreEqual(CLType.PublicKey, mapType.KeyType.Type); + Assert.AreEqual(CLType.U256, mapType.ValueType.Type); + } + + [Test] + public void ParseSchema_TupleType_CorrectInnerTypes() + { + // Schema of a CEP-18 contract + var schemaHex = + "07000000040000004275726e02000000050000006f776e65720b06000000616d6f756e74071100000044656372656173" + + "65416c6c6f77616e636504000000050000006f776e65720b070000007370656e6465720b09000000616c6c6f77616e63" + + "650707000000646563725f62790711000000496e637265617365416c6c6f77616e636504000000050000006f776e6572"+ + "0b070000007370656e6465720b09000000616c6c6f77616e63650706000000696e635f627907040000004d696e740200" + + "000009000000726563697069656e740b06000000616d6f756e74070c000000536574416c6c6f77616e63650300000005" + + "0000006f776e65720b070000007370656e6465720b09000000616c6c6f77616e636507080000005472616e7366657203" + + "0000000600000073656e6465720b09000000726563697069656e740b06000000616d6f756e74070c0000005472616e73" + + "66657246726f6d04000000070000007370656e6465720b050000006f776e65720b09000000726563697069656e740b06" + + "000000616d6f756e7407"; + + var schema = CESParser.ParseSchema(Hex.Decode(schemaHex)); + Assert.IsNotNull(schema); + schema.TryGetEventSchema("Transfer", out var eventSchema); + Assert.IsNotNull(eventSchema); + Assert.AreEqual("Transfer", eventSchema.EventName); + + var evt0 = + "0a0000006576656e745f4d696e74011262d06e53125ea098187fb4d1d5b10a7afed48e5e5eef182ed992fc5b10034908000064a7b3b6e00d"; + var parsedEvt = CESParser.ParseEvent(Hex.Decode(evt0), schema); + Assert.IsNotNull(parsedEvt); + Assert.AreEqual("event_Mint", parsedEvt.Name); + var amount = parsedEvt["amount"]; + amount.Value + } + } +} diff --git a/Casper.Network.SDK/CES/CESEvent.cs b/Casper.Network.SDK/CES/CESEvent.cs new file mode 100644 index 0000000..9a11075 --- /dev/null +++ b/Casper.Network.SDK/CES/CESEvent.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Casper.Network.SDK.Types; + +namespace Casper.Network.SDK.CES +{ + /// + /// Defines one field in a CES event schema: its name and Casper type. + /// + public class CESEventSchemaField + { + public string Name { get; } + public CLTypeInfo CLTypeInfo { get; } + + public CESEventSchemaField(string name, CLTypeInfo clTypeInfo) + { + Name = name; + CLTypeInfo = clTypeInfo; + } + } + + /// + /// Schema for a single named CES event type, listing its fields in order. + /// + public class CESEventSchema + { + public string EventName { get; } + public IReadOnlyList Fields { get; } + + public CESEventSchema(string eventName, IReadOnlyList fields) + { + EventName = eventName; + Fields = fields; + } + } + + /// + /// Full schema for a CES-compliant contract, parsed from the __events_schema named key. + /// Maps event names to their . + /// + public class CESContractSchema + { + public IReadOnlyDictionary Events { get; } + + public CESContractSchema(IReadOnlyDictionary events) + { + Events = events; + } + + /// + /// Retrieves the schema for the given event name. + /// + public CESEventSchema GetEventSchema(string eventName) + { + if (!TryGetEventSchema(eventName, out var schema)) + throw new KeyNotFoundException($"Event '{eventName}' not found in the contract schema."); + return schema; + } + + /// + /// Tries to retrieve the schema for the given event name. + /// + public bool TryGetEventSchema(string eventName, out CESEventSchema schema) + { + return Events.TryGetValue(eventName, out schema); + } + } + + /// + /// A fully parsed CES event containing the event name and its typed fields. + /// + public class CESEvent + { + public string Name { get; } + public IReadOnlyList Fields { get; } + + public CESEvent(string name, IReadOnlyList fields) + { + Name = name; + Fields = fields; + } + + /// + /// Returns the field with the given name, or null if not found. + /// + public NamedArg this[string fieldName] => + Fields.FirstOrDefault(f => f.Name == fieldName); + } +} diff --git a/Casper.Network.SDK/CES/CESParser.cs b/Casper.Network.SDK/CES/CESParser.cs new file mode 100644 index 0000000..ebd3443 --- /dev/null +++ b/Casper.Network.SDK/CES/CESParser.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Casper.Network.SDK.Types; +using Casper.Network.SDK.Utils; + +namespace Casper.Network.SDK.CES +{ + /// + /// Parses CES (Casper Event Standard) schema and event bytes produced by + /// smart contracts following the make-software CES convention. + /// + /// Contracts store their event schema in the named key __events_schema + /// (a CLValue of type Any) and emit events into __events + /// (a CLValue of type Map(U32, Bytes)). + /// + /// + public static class CESParser + { + /// + /// Parses the raw bytes of the __events_schema CLValue (type Any) + /// into a . + /// + /// + /// The Bytes property of the CLValue retrieved from __events_schema. + /// + public static CESContractSchema ParseSchema(byte[] rawBytes) + { + using var ms = new MemoryStream(rawBytes); + using var reader = new BinaryReader(ms); + + var numEvents = reader.ReadInt32(); + var events = new Dictionary(numEvents); + + for (int i = 0; i < numEvents; i++) + { + var eventName = reader.ReadCLString(); + + var numFields = reader.ReadInt32(); + var fields = new List(numFields); + + for (int j = 0; j < numFields; j++) + { + var fieldName = reader.ReadCLString(); + var fieldType = ReadCLTypeInfo(reader); + fields.Add(new CESEventSchemaField(fieldName, fieldType)); + } + + events[eventName] = new CESEventSchema(eventName, fields); + } + + return new CESContractSchema(events); + } + + /// + /// Parses a single CES event from its raw bytes using the given contract schema. + /// + /// + /// The raw bytes of one event entry (value) from the __events CLValue map. + /// + /// + /// The contract schema obtained from . + /// + /// + /// Thrown when the event name found in is not present in + /// . + /// + public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema) + { + // Some CES implementations wrap the event payload in a Casper Vec (Bytes), + // which prepends a u32 LE length equal to the remaining byte count. Detect and + // skip that outer wrapper transparently. + var offset = 0; + if (rawBytes.Length >= 4) + { + var declaredLen = BitConverter.ToUInt32(rawBytes, 0); + if (!BitConverter.IsLittleEndian) + declaredLen = SwapU32(declaredLen); + if (declaredLen == rawBytes.Length - 4) + offset = 4; + } + + using var ms = new MemoryStream(rawBytes, offset, rawBytes.Length - offset); + using var reader = new BinaryReader(ms); + + var rawName = reader.ReadCLString(); + + // Strip the "event_" prefix that some CES implementations prepend to event names. + const string prefix = "event_"; + var eventName = rawName.StartsWith(prefix) + ? rawName.Substring(prefix.Length) + : rawName; + + if (!schema.TryGetEventSchema(eventName, out var eventSchema)) + throw new KeyNotFoundException( + $"Event '{rawName}' not found in the contract schema."); + + var fields = new List(eventSchema.Fields.Count); + + foreach (var schemaField in eventSchema.Fields) + { + var fwType = schemaField.CLTypeInfo.GetFrameworkType(); + var value = reader.ReadCLItem(schemaField.CLTypeInfo, fwType); + var clValue = BuildCLValue(value, schemaField.CLTypeInfo); + fields.Add(new NamedArg(schemaField.Name, clValue)); + } + + return new CESEvent(rawName, fields); + } + + private static uint SwapU32(uint value) => + ((value & 0x000000FFu) << 24) | + ((value & 0x0000FF00u) << 8) | + ((value & 0x00FF0000u) >> 8) | + ((value & 0xFF000000u) >> 24); + + /// + /// Reads a from the binary stream. + /// Mirrors CLValueByteSerializer.CLTypeToBytes() in reverse. + /// + private static CLTypeInfo ReadCLTypeInfo(BinaryReader reader) + { + var tag = (CLType)reader.ReadByte(); + + return tag switch + { + CLType.Option => new CLOptionTypeInfo(ReadCLTypeInfo(reader)), + CLType.List => new CLListTypeInfo(ReadCLTypeInfo(reader)), + CLType.ByteArray => new CLByteArrayTypeInfo(reader.ReadInt32()), + CLType.Result => new CLResultTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Map => new CLMapTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Tuple1 => new CLTuple1TypeInfo(ReadCLTypeInfo(reader)), + CLType.Tuple2 => new CLTuple2TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Tuple3 => new CLTuple3TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + _ => new CLTypeInfo(tag) + }; + } + + /// + /// Wraps a deserialized field value back into a so that callers + /// can use the standard CLValue.ToXxx() conversion methods on each field. + /// + private static CLValue BuildCLValue(object value, CLTypeInfo typeInfo) + { + return typeInfo.Type switch + { + CLType.Bool => CLValue.Bool((bool)value), + CLType.I32 => CLValue.I32((int)value), + CLType.I64 => CLValue.I64((long)value), + CLType.U8 => CLValue.U8((byte)value), + CLType.U32 => CLValue.U32((uint)value), + CLType.U64 => CLValue.U64((ulong)value), + CLType.U128 => CLValue.U128((System.Numerics.BigInteger)value), + CLType.U256 => CLValue.U256((System.Numerics.BigInteger)value), + CLType.U512 => CLValue.U512((System.Numerics.BigInteger)value), + CLType.Unit => CLValue.Unit(), + CLType.String => CLValue.String((string)value), + CLType.URef => CLValue.URef((URef)value), + CLType.PublicKey => CLValue.PublicKey((PublicKey)value), + CLType.Key => CLValue.Key((GlobalStateKey)value), + _ => new CLValue(SerializeValue(value, typeInfo), typeInfo, value) + }; + } + + /// + /// Re-serializes a complex CLValue (Option, List, Map, etc.) to bytes so it can be + /// stored inside a wrapper. Uses the SDK's existing serializer. + /// + private static byte[] SerializeValue(object value, CLTypeInfo typeInfo) + { + var serializer = new ByteSerializers.CLValueByteSerializer(); + // Build a temporary CLValue to get its inner bytes via the serializer. + // For complex types we need the raw data bytes, which are the first part of + // the full serialization (data_length + data_bytes + type_bytes). + using var ms = new MemoryStream(); + + switch (typeInfo) + { + case CLOptionTypeInfo optionType: + { + if (value == null) + { + ms.WriteByte(0x00); + } + else + { + ms.WriteByte(0x01); + var inner = BuildCLValue(value, optionType.OptionType); + ms.Write(inner.Bytes); + } + break; + } + case CLListTypeInfo listType: + { + var list = (System.Collections.IList)value; + var lenBytes = BitConverter.GetBytes(list.Count); + if (!BitConverter.IsLittleEndian) Array.Reverse(lenBytes); + ms.Write(lenBytes); + foreach (var item in list) + { + var inner = BuildCLValue(item, listType.ListType); + ms.Write(inner.Bytes); + } + break; + } + case CLMapTypeInfo mapType: + { + var dict = (System.Collections.IDictionary)value; + var lenBytes = BitConverter.GetBytes(dict.Count); + if (!BitConverter.IsLittleEndian) Array.Reverse(lenBytes); + ms.Write(lenBytes); + foreach (System.Collections.DictionaryEntry kv in dict) + { + var k = BuildCLValue(kv.Key, mapType.KeyType); + var v = BuildCLValue(kv.Value, mapType.ValueType); + ms.Write(k.Bytes); + ms.Write(v.Bytes); + } + break; + } + case CLByteArrayTypeInfo baType: + { + ms.Write((byte[])value); + break; + } + default: + throw new NotSupportedException( + $"SerializeValue not implemented for CLType '{typeInfo.Type}'."); + } + + return ms.ToArray(); + } + } +} diff --git a/Docs/Articles/CasperEventStandard.md b/Docs/Articles/CasperEventStandard.md new file mode 100644 index 0000000..7883148 --- /dev/null +++ b/Docs/Articles/CasperEventStandard.md @@ -0,0 +1,188 @@ +# Casper Event Standard (CES) + +The **Casper Event Standard (CES)** is a convention adopted by make-software for emitting and consuming typed events from Casper smart contracts. Contracts that follow CES store a self-describing schema alongside their events, making it possible to decode any event without out-of-band knowledge of its structure. + +The Casper .NET SDK provides `CESParser` in the `Casper.Network.SDK.CES` namespace to handle both schema and event parsing. + +--- + +## How CES Works + +A CES-compliant contract uses two special named keys: + +| Named key | CLType | Purpose | +|---|---|---| +| `__events_schema` | `Any` | Binary-encoded schema listing every event type and its fields | +| `__events` | `Map(U32, Bytes)` | Ordered map of event index → raw event bytes | + +### Schema (`__events_schema`) + +The schema describes every event the contract can emit. It is stored as a CLValue of type `Any`, whose raw bytes encode a sequence of event descriptors: + +``` +u32 LE — number of events +for each event: + String — event name (4-byte LE length + UTF-8 bytes) + u32 LE — number of fields + for each field: + String — field name + CLType bytes — Casper binary type encoding (tag byte + optional inner types) +``` + +All integers are little-endian. The `String` encoding used here is the standard Casper string encoding: a `u32` length followed by the UTF-8 content (no null terminator). + +### Events (`__events`) + +Each entry in the `__events` map is a `Bytes` (`Vec`) value. The raw bytes of one event entry are laid out as follows: + +``` +u32 LE — Vec outer length (= remaining bytes; auto-detected and skipped) +String — event name, prefixed with "event_" (e.g. "event_Mint") +for each field (in schema order): + raw field bytes — native Casper serialization, no CLValue wrapper +``` + +The `"event_"` prefix is stripped internally when looking up the event in the schema, but `CESEvent.Name` preserves the original name exactly as stored in the bytes. + +### CLType Binary Encoding + +CLType tags used in the schema are single bytes: + +| CLType | Tag | +|---|---| +| `Bool` | `0x00` | +| `I32` | `0x01` | +| `I64` | `0x02` | +| `U8` | `0x03` | +| `U32` | `0x04` | +| `U64` | `0x05` | +| `U128` | `0x06` | +| `U256` | `0x07` | +| `U512` | `0x08` | +| `Unit` | `0x09` | +| `String` | `0x0a` | +| `Key` | `0x0b` | +| `URef` | `0x0c` | +| `Option` | `0x0d` + inner type | +| `List` | `0x0e` + item type | +| `ByteArray` | `0x0f` + `u32` size | +| `Result` | `0x10` + ok type + err type | +| `Map` | `0x11` + key type + value type | +| `Tuple1` | `0x12` + type₁ | +| `Tuple2` | `0x13` + type₁ + type₂ | +| `Tuple3` | `0x14` + type₁ + type₂ + type₃ | +| `Any` | `0x15` | +| `PublicKey` | `0x16` | + +Compound types (Option, List, Map, etc.) are encoded recursively — the tag is followed immediately by its inner type tags. + +--- + +## API Overview + +### Parsing the Schema + +Retrieve the `__events_schema` named key from the contract's global state, extract the raw bytes from the CLValue, and pass them to `CESParser.ParseSchema`: + +```csharp +using Casper.Network.SDK.CES; + +byte[] schemaBytes = /* raw bytes from __events_schema CLValue */; +CESContractSchema schema = CESParser.ParseSchema(schemaBytes); + +// Check what events the contract supports +foreach (var kvp in schema.Events) +{ + Console.WriteLine($"Event: {kvp.Key}"); + foreach (var field in kvp.Value.Fields) + Console.WriteLine($" {field.Name}: {field.CLTypeInfo.Type}"); +} +``` + +### Parsing an Event + +Retrieve the value bytes for one entry from `__events` and pass them together with the schema to `CESParser.ParseEvent`: + +```csharp +byte[] eventBytes = /* raw bytes of one entry from __events map */; +CESEvent evt = CESParser.ParseEvent(eventBytes, schema); + +Console.WriteLine(evt.Name); // e.g. "event_Mint" + +// Access fields by name +NamedArg amount = evt["amount"]; +Console.WriteLine(amount.Value.ToBigInteger()); // 1000000000000000000 + +NamedArg recipient = evt["recipient"]; +Console.WriteLine(recipient.Value.ToString()); // hash-1262d0... +``` + +Each field in `CESEvent.Fields` is a `NamedArg`, exposing the field name and a fully-typed `CLValue` that supports the standard `ToXxx()` conversion methods (`ToBigInteger()`, `ToString()`, `ToBoolean()`, etc.). + +### Looking Up an Event Schema + +```csharp +if (schema.TryGetEventSchema("Mint", out CESEventSchema mintSchema)) +{ + Console.WriteLine($"Mint has {mintSchema.Fields.Count} fields."); +} + +// Throws KeyNotFoundException if not found: +CESEventSchema transferSchema = schema.GetEventSchema("Transfer"); +``` + +--- + +## Data Model + +``` +CESContractSchema + └── Events: Dictionary + └── CESEventSchema + ├── EventName: string + └── Fields: IReadOnlyList + ├── Name: string + └── CLTypeInfo: CLTypeInfo + +CESEvent + ├── Name: string (raw name, e.g. "event_Transfer") + └── Fields: IReadOnlyList + ├── Name: string + └── Value: CLValue +``` + +--- + +## Real-World Example — CEP-18 Token Contract + +A CEP-18 fungible token contract exposes seven events: `Burn`, `DecreaseAllowance`, `IncreaseAllowance`, `Mint`, `SetAllowance`, `Transfer`, and `TransferFrom`. + +A `Mint` event byte payload (hex) looks like this: + +``` +38000000 ← Vec length = 56 bytes +0a000000 ← String length = 10 +6576656e745f4d696e74 ← "event_Mint" +01 ← recipient: Key tag (Account/Hash) +1262d06e...349 ← 32-byte key hash +08000000 ← amount: U512, 8 bytes +64a7b3b6e00d0000 ← 1 000 000 000 000 000 000 +``` + +After parsing: + +```csharp +Console.WriteLine(evt.Name); // event_Mint +Console.WriteLine(evt["recipient"]); // hash-1262d06e...349 +Console.WriteLine(evt["amount"] + .Value.ToBigInteger()); // 1000000000000000000 +``` + +--- + +## Notes + +- **Schema key** (`__events_schema`) and **events key** (`__events`) are both stored as named keys directly on the contract's `StoredContractByHash` or `StoredContractByName` entity. +- The `Vec` outer length prefix is auto-detected and skipped transparently by `ParseEvent`. +- `CESEvent.Name` always reflects the exact string found in the event bytes, including the `"event_"` prefix. Schema lookup normalises the name by stripping that prefix automatically. +- This implementation targets the Casper 1.x named-key storage pattern. Casper 2.x may use a different mechanism. From 5f1aae5353a970b6e5487fa96af706e95c174feb Mon Sep 17 00:00:00 2001 From: David Hernando Date: Tue, 3 Mar 2026 22:05:29 +0100 Subject: [PATCH 2/5] added CES Parser (WIP). Signed-off-by: David Hernando --- .../CESJsonSerializerTest.cs | 277 +++++++ .../CESParserGetEventsTest.cs | 683 ++++++++++++++++++ Casper.Network.SDK.Test/CESParserTest.cs | 44 +- Casper.Network.SDK.Test/CLValueReaderTest.cs | 650 +++++++++++++++++ .../ByteSerializers/CLValueByteSerializer.cs | 2 +- Casper.Network.SDK/CES/CESEvent.cs | 224 ++++-- Casper.Network.SDK/CES/CESParser.cs | 245 ++----- Casper.Network.SDK/CES/CESSchema.cs | 345 +++++++++ Casper.Network.SDK/Types/CLValueReader.cs | 233 ++++++ .../Types/GlobalStateKey/GlobalStateKey.cs | 3 + .../Utils/BinaryReaderExtensions.cs | 21 + 11 files changed, 2456 insertions(+), 271 deletions(-) create mode 100644 Casper.Network.SDK.Test/CESJsonSerializerTest.cs create mode 100644 Casper.Network.SDK.Test/CESParserGetEventsTest.cs create mode 100644 Casper.Network.SDK.Test/CLValueReaderTest.cs create mode 100644 Casper.Network.SDK/CES/CESSchema.cs create mode 100644 Casper.Network.SDK/Types/CLValueReader.cs diff --git a/Casper.Network.SDK.Test/CESJsonSerializerTest.cs b/Casper.Network.SDK.Test/CESJsonSerializerTest.cs new file mode 100644 index 0000000..2886f60 --- /dev/null +++ b/Casper.Network.SDK.Test/CESJsonSerializerTest.cs @@ -0,0 +1,277 @@ +using System.Numerics; +using System.Text.Json; +using Casper.Network.SDK.CES; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + /// + /// Verifies that and round-trip + /// correctly through System.Text.Json serialization/deserialization using the + /// [JsonPropertyName] and [JsonConstructor] annotations added to those classes. + /// + public class CESJsonSerializerTest + { + // ── shared fixtures (same as CESParserTest) ─────────────────────────── + // + // Schema: one event "Transfer" with fields amount (U512) and sender (String) + private static readonly string SchemaHex = string.Concat( + "01000000", + "08000000", + "5472616e73666572", + "02000000", + "06000000", "616d6f756e74", "08", + "06000000", "73656e646572", "0a" + ); + + // event_Transfer(amount=100, sender="Alice") + private static readonly string EventHex = string.Concat( + "0e000000", + "6576656e745f5472616e73666572", + "01", "64", + "05000000", "416c696365" + ); + + private const string ContractHash = "hash-aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + private const string ContractPkgHash = "contract-package-hash-1122334411223344112233441122334411223344112233441122334411223344"; + + // ── helpers ─────────────────────────────────────────────────────────── + + private static CESContractSchema BuildAnnotatedSchema() => + new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { + ContractHash = ContractHash, + ContractPackageHash = ContractPkgHash, + }; + + private static CESEvent BuildAnnotatedEvent() + { + var schema = BuildAnnotatedSchema(); + return CESEvent.ParseEvent(Hex.Decode(EventHex), schema, transformId: 3, eventId: "42"); + } + + // ── CESEventSchemaField ─────────────────────────────────────────────── + + [Test] + public void SchemaField_RoundTrip_PreservesNameAndCLType() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var field = schema.Events["Transfer"].Fields[0]; + + var json = JsonSerializer.Serialize(field); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(field.Name, restored.Name); + Assert.AreEqual(field.CLTypeInfo.Type, restored.CLTypeInfo.Type); + } + + [Test] + public void SchemaField_Serializes_SnakeCasePropertyNames() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var field = schema.Events["Transfer"].Fields[0]; + + var json = JsonSerializer.Serialize(field); + + Assert.IsTrue(json.Contains("\"name\"")); + Assert.IsTrue(json.Contains("\"cl_type\"")); + } + + // ── CESEventSchema ──────────────────────────────────────────────────── + + [Test] + public void EventSchema_RoundTrip_PreservesEventNameAndFields() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var eventSchema = schema.Events["Transfer"]; + + var json = JsonSerializer.Serialize(eventSchema); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual("Transfer", restored.EventName); + Assert.AreEqual(2, restored.Fields.Count); + Assert.AreEqual("amount", restored.Fields[0].Name); + Assert.AreEqual(CLType.U512, restored.Fields[0].CLTypeInfo.Type); + Assert.AreEqual("sender", restored.Fields[1].Name); + Assert.AreEqual(CLType.String, restored.Fields[1].CLTypeInfo.Type); + } + + [Test] + public void EventSchema_Serializes_SnakeCasePropertyNames() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var eventSchema = schema.Events["Transfer"]; + + var json = JsonSerializer.Serialize(eventSchema); + + Assert.IsTrue(json.Contains("\"event_name\"")); + Assert.IsTrue(json.Contains("\"fields\"")); + } + + // ── CESContractSchema ───────────────────────────────────────────────── + + [Test] + public void ContractSchema_RoundTrip_PreservesEvents() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.IsNotNull(restored); + Assert.IsTrue(restored.TryGetEventSchema("Transfer", out var evt)); + Assert.AreEqual("Transfer", evt.EventName); + Assert.AreEqual(2, evt.Fields.Count); + } + + [Test] + public void ContractSchema_RoundTrip_PreservesContractHash() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(ContractHash, restored.ContractHash); + } + + [Test] + public void ContractSchema_RoundTrip_PreservesContractPackageHash() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(ContractPkgHash, restored.ContractPackageHash); + } + + [Test] + public void ContractSchema_Serializes_SnakeCasePropertyNames() + { + var schema = BuildAnnotatedSchema(); + + var json = JsonSerializer.Serialize(schema); + + Assert.IsTrue(json.Contains("\"events\"")); + Assert.IsTrue(json.Contains("\"contract_hash\"")); + Assert.IsTrue(json.Contains("\"contract_package_hash\"")); + } + + [Test] + public void ContractSchema_WithNullHashes_RoundTripPreservesNulls() + { + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + + var json = JsonSerializer.Serialize(schema); + var restored = JsonSerializer.Deserialize(json); + + Assert.IsNull(restored.ContractHash); + Assert.IsNull(restored.ContractPackageHash); + } + + // ── CESEvent ────────────────────────────────────────────────────────── + + [Test] + public void CESEvent_RoundTrip_PreservesName() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual("event_Transfer", restored.Name); + } + + [Test] + public void CESEvent_RoundTrip_PreservesFieldCount() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(2, restored.Fields.Count); + } + + [Test] + public void CESEvent_RoundTrip_PreservesU512Field() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + var amount = restored["amount"]; + Assert.IsNotNull(amount); + Assert.AreEqual(CLType.U512, amount.Value.TypeInfo.Type); + Assert.AreEqual(new BigInteger(100), amount.Value.ToBigInteger()); + } + + [Test] + public void CESEvent_RoundTrip_PreservesStringField() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + var sender = restored["sender"]; + Assert.IsNotNull(sender); + Assert.AreEqual(CLType.String, sender.Value.TypeInfo.Type); + Assert.AreEqual("Alice", sender.Value.ToString()); + } + + [Test] + public void CESEvent_RoundTrip_PreservesContextFields() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + var restored = JsonSerializer.Deserialize(json); + + Assert.AreEqual(ContractHash, restored.ContractHash); + Assert.AreEqual(ContractPkgHash, restored.ContractPackageHash); + Assert.AreEqual(3, restored.TransformId); + Assert.AreEqual("42", restored.EventId); + } + + [Test] + public void CESEvent_Serializes_SnakeCasePropertyNames() + { + var evt = BuildAnnotatedEvent(); + + var json = JsonSerializer.Serialize(evt); + + Assert.IsTrue(json.Contains("\"name\"")); + Assert.IsTrue(json.Contains("\"fields\"")); + Assert.IsTrue(json.Contains("\"contract_hash\"")); + Assert.IsTrue(json.Contains("\"contract_package_hash\"")); + Assert.IsTrue(json.Contains("\"transform_id\"")); + Assert.IsTrue(json.Contains("\"event_id\"")); + } + + [Test] + public void CESEvent_FieldsSerializeAsArrayOfPairs() + { + var evt = BuildAnnotatedEvent(); + + // Each field must serialize as ["fieldName", {CLValue}], not as an object. + var json = JsonSerializer.Serialize(evt); + using var doc = JsonDocument.Parse(json); + var fields = doc.RootElement.GetProperty("fields"); + + Assert.AreEqual(JsonValueKind.Array, fields.ValueKind); + + var first = fields[0]; + Assert.AreEqual(JsonValueKind.Array, first.ValueKind); + Assert.AreEqual("amount", first[0].GetString()); + Assert.AreEqual(JsonValueKind.Object, first[1].ValueKind); + + var second = fields[1]; + Assert.AreEqual("sender", second[0].GetString()); + } + } +} diff --git a/Casper.Network.SDK.Test/CESParserGetEventsTest.cs b/Casper.Network.SDK.Test/CESParserGetEventsTest.cs new file mode 100644 index 0000000..c4151f3 --- /dev/null +++ b/Casper.Network.SDK.Test/CESParserGetEventsTest.cs @@ -0,0 +1,683 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using Casper.Network.SDK.ByteSerializers; +using Casper.Network.SDK.CES; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + [TestFixture] + public class CESParserGetEventsTest + { + // ─── shared schema / event fixtures ──────────────────────────────────── + // + // Schema: one event "Transfer" with fields amount (U512) and sender (String) + + private static readonly string SchemaHex = string.Concat( + "01000000", // 1 event + "08000000", // len("Transfer") = 8 + "5472616e73666572", // "Transfer" + "02000000", // 2 fields + "06000000", // len("amount") = 6 + "616d6f756e74", // "amount" + "08", // CLType.U512 + "06000000", // len("sender") = 6 + "73656e646572", // "sender" + "0a" // CLType.String + ); + + // event_Transfer(amount=100, sender="Alice") + // U512(100) → length byte 0x01 + value 0x64 + // String → 4-byte LE length + UTF-8 + private static readonly string EventHex = string.Concat( + "0e000000", // CLString len = 14 + "6576656e745f5472616e73666572", // "event_Transfer" + "01", "64", // U512(100) + "05000000", "416c696365" // String("Alice") + ); + + // event_Transfer(amount=200, sender="Bob") + private static readonly string EventBobHex = string.Concat( + "0e000000", // CLString len = 14 + "6576656e745f5472616e73666572", // "event_Transfer" + "01", "c8", // U512(200) + "03000000", "426f62" // String("Bob") + ); + + // Schema for a second contract: one event "Mint" with field recipient (U512) + private static readonly string SchemaHex2 = string.Concat( + "01000000", // 1 event + "04000000", // len("Mint") = 4 + "4d696e74", // "Mint" + "01000000", // 1 field + "09000000", // len("recipient") = 9 + "726563697069656e74", // "recipient" + "08" // CLType.U512 + ); + + // event_Mint(recipient=200) + private static readonly string EventMintHex = string.Concat( + "0a000000", // CLString len = 10 + "6576656e745f4d696e74", // "event_Mint" + "01", "c8" // U512(200) + ); + + // 32-byte seeds (hex-encoded, 64 chars each) + private const string SeedHex = + "aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + + private const string SeedHex2 = + "1122334411223344112233441122334411223344112233441122334411223344"; + + // 32-byte dictionary global-state key hashes (different from the seeds) + private const string DictKeyHex = + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + + private const string DictKeyHex2 = + "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe"; + + // ─── helpers ─────────────────────────────────────────────────────────── + + private static CESContractSchema ParseSchema(string schemaHex) => + CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); + + private static CESContractSchema MakeWatchedSchema(string schemaHex, string seedHex) => + new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(schemaHex)).Events) + { + EventsURef = new URef($"uref-{seedHex}-000") + }; + + /// + /// Builds the raw bytes that expects. + /// Format: + /// [4B outer length] [CLValue blob (length + data + type)] [4B seed len] [seed bytes] + /// [4B key len] [key bytes] + /// The event payload is wrapped as a CLValue.ByteArray so that + /// dict.Value equals the original bytes. + /// + private static byte[] BuildDictBytes(byte[] eventPayload, byte[] seedBytes, string itemKey) + { + // CLValueByteSerializer.ToBytes produces the exact bytes that FromReader consumes: + // [4B data length] [N data bytes] [type bytes] + var clValueBlob = new CLValueByteSerializer().ToBytes(CLValue.ByteArray(eventPayload)); + + using var ms = new MemoryStream(); + + // outer length = total bytes that FromReader() will read + WriteU32LE(ms, (uint)clValueBlob.Length); + ms.Write(clValueBlob, 0, clValueBlob.Length); + + // seed: 4-byte LE count + raw bytes + WriteU32LE(ms, (uint)seedBytes.Length); + ms.Write(seedBytes, 0, seedBytes.Length); + + // item key: 4-byte LE count + UTF-8 + var keyBytes = System.Text.Encoding.UTF8.GetBytes(itemKey); + WriteU32LE(ms, (uint)keyBytes.Length); + ms.Write(keyBytes, 0, keyBytes.Length); + + return ms.ToArray(); + } + + private static void WriteU32LE(MemoryStream ms, uint value) + { + var bytes = BitConverter.GetBytes(value); + if (!BitConverter.IsLittleEndian) Array.Reverse(bytes); + ms.Write(bytes, 0, bytes.Length); + } + + /// + /// Creates a that looks exactly like a CES dictionary write. + /// + private static Transform MakeCESTransform( + string dictKeyHex, byte[] eventPayload, byte[] seedBytes, string itemKey = "1") + { + var clValue = new CLValue( + BuildDictBytes(eventPayload, seedBytes, itemKey), + new CLTypeInfo(CLType.Any)); + + return new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{dictKeyHex}"), + Kind = new WriteTransformKind { Value = new StoredValue { CLValue = clValue } } + }; + } + + // ─── guard tests ─────────────────────────────────────────────────────── + + [Test] + public void GetEvents_NullTransforms_ThrowsArgumentNullException() + { + Assert.Throws(() => + CESParser.GetEvents(null, new List())); + } + + [Test] + public void GetEvents_NullWatchedContracts_ThrowsArgumentNullException() + { + Assert.Throws(() => + CESParser.GetEvents(new List(), null)); + } + + // ─── empty inputs ────────────────────────────────────────────────────── + + [Test] + public void GetEvents_EmptyTransforms_ReturnsEmptyList() + { + var result = CESParser.GetEvents( + new List(), + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_EmptyWatchedContracts_ReturnsEmptyList() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + + var result = CESParser.GetEvents( + new List { transform }, + new List()); + + Assert.AreEqual(0, result.Count); + } + + // ─── filter: key type ────────────────────────────────────────────────── + + [Test] + public void GetEvents_AccountHashKeyTransform_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new WriteTransformKind + { + Value = new StoredValue + { + CLValue = new CLValue( + BuildDictBytes(Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"), + new CLTypeInfo(CLType.Any)) + } + } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_HashKeyTransform_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString( + "hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new WriteTransformKind + { + Value = new StoredValue + { + CLValue = new CLValue( + BuildDictBytes(Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"), + new CLTypeInfo(CLType.Any)) + } + } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + // ─── filter: transform kind ──────────────────────────────────────────── + + [Test] + public void GetEvents_IdentityTransformOnDictionaryKey_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new IdentityTransformKind() + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_AddInt32TransformOnDictionaryKey_IsSkipped() + { + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new AddInt32TransformKind { Value = 42 } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_WriteWithoutCLValue_IsSkipped() + { + // Write of an Account (or any other StoredValue without CLValue set) + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new WriteTransformKind { Value = new StoredValue() } + }; + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + // ─── filter: seed matching ───────────────────────────────────────────── + + [Test] + public void GetEvents_DictionaryWriteWithUnwatchedSeed_IsSkipped() + { + const string differentSeed = + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + + var transform = MakeCESTransform( + DictKeyHex, Hex.Decode(EventHex), Hex.Decode(differentSeed)); + + var result = CESParser.GetEvents( + new List { transform }, + new List { MakeWatchedSchema(SchemaHex, SeedHex) }); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_SeedMatchIgnoresAccessRights() + { + // CLValueDictionary.Parse always creates the seed with AccessRights.NONE. + // The watched-contract key is the plain 32-byte hash (no access rights). + // GetEvents must still find the match. + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(1, result.Count); + } + + // ─── happy-path: single event ────────────────────────────────────────── + + [Test] + public void GetEvents_SingleMatchingTransform_ReturnsSingleEvent() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(1, result.Count); + } + + [Test] + public void GetEvents_SingleMatchingTransform_CorrectEventName() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual("event_Transfer", result[0].Name); + } + + [Test] + public void GetEvents_SingleMatchingTransform_CorrectU512Field() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(new BigInteger(100), result[0]["amount"].Value.ToBigInteger()); + } + + [Test] + public void GetEvents_SingleMatchingTransform_CorrectStringField() + { + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual("Alice", result[0]["sender"].Value.ToString()); + } + + // ─── graceful skipping ───────────────────────────────────────────────── + + [Test] + public void GetEvents_EventNameAbsentFromSchema_IsSkipped() + { + // EventMintHex has name "event_Mint", but the watched schema only knows "Transfer". + // ParseEvent throws KeyNotFoundException → GetEvents silently skips. + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventMintHex), Hex.Decode(SeedHex)); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(0, result.Count); + } + + [Test] + public void GetEvents_UnparsableDictionaryBytes_IsSkipped() + { + // Garbage bytes that CLValueDictionary.Parse will fail on → skipped, no exception. + var clValue = new CLValue(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, new CLTypeInfo(CLType.Any)); + var transform = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new WriteTransformKind { Value = new StoredValue { CLValue = clValue } } + }; + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + Assert.DoesNotThrow(() => + { + var result = CESParser.GetEvents(new List { transform }, watched); + Assert.AreEqual(0, result.Count); + }); + } + + // ─── multiple events ─────────────────────────────────────────────────── + + [Test] + public void GetEvents_TwoEventsFromSameContract_ReturnsBoth() + { + // Two dictionary writes with the same seed but different item keys + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "2"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + } + + [Test] + public void GetEvents_TwoEventsFromDifferentContracts_ReturnsBoth() + { + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventMintHex), Hex.Decode(SeedHex2)); + var watched = new List + { + MakeWatchedSchema(SchemaHex, SeedHex), + MakeWatchedSchema(SchemaHex2, SeedHex2), + }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + } + + [Test] + public void GetEvents_TwoEventsFromDifferentContracts_CorrectFieldValues() + { + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventMintHex), Hex.Decode(SeedHex2)); + var watched = new List + { + MakeWatchedSchema(SchemaHex, SeedHex), + MakeWatchedSchema(SchemaHex2, SeedHex2), + }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + // first event: Transfer – check both fields + Assert.AreEqual("event_Transfer", result[0].Name); + Assert.AreEqual(new BigInteger(100), result[0]["amount"].Value.ToBigInteger()); + Assert.AreEqual("Alice", result[0]["sender"].Value.ToString()); + + // second event: Mint – check recipient + Assert.AreEqual("event_Mint", result[1].Name); + Assert.AreEqual(new BigInteger(200), result[1]["recipient"].Value.ToBigInteger()); + } + + // ─── ordering and mixed transforms ──────────────────────────────────── + + [Test] + public void GetEvents_OrderPreserved_MatchesTransformOrder() + { + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + // Alice (item "1") comes before Bob (item "2") in the transform list + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "2"); + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual("Alice", result[0]["sender"].Value.ToString()); + Assert.AreEqual("Bob", result[1]["sender"].Value.ToString()); + } + + [Test] + public void GetEvents_MixedTransforms_OnlyMatchingEventsReturned() + { + // t1: Identity on a dictionary key → skipped (wrong kind) + var t1 = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex}"), + Kind = new IdentityTransformKind() + }; + // t2: CES event from watched contract → included + var t2 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + // t3: Write of CLValue to an account-hash key → skipped (wrong key type) + var t3 = new Transform + { + Key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new WriteTransformKind { Value = new StoredValue { CLValue = CLValue.U32(1) } } + }; + // t4: dictionary write with an unwatched seed → skipped + var t4 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventHex), Hex.Decode(SeedHex2)); + + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t1, t2, t3, t4 }, watched); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("event_Transfer", result[0].Name); + } + + [Test] + public void GetEvents_OneValidOneBadParseable_OnlyValidEventReturned() + { + // t1: valid CES event + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + // t2: garbled CLValue bytes that will fail CLValueDictionary.Parse → skipped + var t2 = new Transform + { + Key = GlobalStateKey.FromString($"dictionary-{DictKeyHex2}"), + Kind = new WriteTransformKind + { + Value = new StoredValue + { + CLValue = new CLValue(new byte[] { 0x00, 0x01 }, new CLTypeInfo(CLType.Any)) + } + } + }; + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("event_Transfer", result[0].Name); + } + + // ─── TransformId and EventId ─────────────────────────────────────────── + + [Test] + public void GetEvents_SingleEvent_TransformIdIsIndexInList() + { + // t0 is a non-matching transform at index 0; t1 is the CES event at index 1. + var t0 = new Transform + { + Key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"), + Kind = new IdentityTransformKind() + }; + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "7"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0, t1 }, watched); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(1, result[0].TransformId); // index 1 in the list + } + + [Test] + public void GetEvents_SingleEvent_EventIdMatchesDictItemKey() + { + var t0 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), itemKey: "42"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0 }, watched); + + Assert.AreEqual("42", result[0].EventId); + } + + [Test] + public void GetEvents_MultipleEvents_TransformIdsAreDistinct() + { + var t0 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "1"); + var t1 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "2"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0, t1 }, watched); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(0, result[0].TransformId); + Assert.AreEqual(1, result[1].TransformId); + } + + [Test] + public void GetEvents_MultipleEvents_EventIdsMatchItemKeys() + { + var t0 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex), "100"); + var t1 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventBobHex), Hex.Decode(SeedHex), "101"); + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + + var result = CESParser.GetEvents(new List { t0, t1 }, watched); + + Assert.AreEqual("100", result[0].EventId); + Assert.AreEqual("101", result[1].EventId); + } + + // ─── ContractHash / ContractPackageHash propagation ─────────────────── + + [Test] + public void GetEvents_SchemaWithContractHash_EventCarriesContractHash() + { + const string contractHash = "hash-aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + var schema = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { + ContractHash = contractHash, + EventsURef = new URef($"uref-{SeedHex}-000"), + }; + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { schema }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(contractHash, result[0].ContractHash); + } + + [Test] + public void GetEvents_SchemaWithContractPackageHash_EventCarriesContractPackageHash() + { + const string pkgHash = "contract-package-hash-1122334411223344112233441122334411223344112233441122334411223344"; + var schema = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { + ContractPackageHash = pkgHash, + EventsURef = new URef($"uref-{SeedHex}-000"), + }; + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var watched = new List { schema }; + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.AreEqual(pkgHash, result[0].ContractPackageHash); + } + + [Test] + public void GetEvents_SchemaWithoutHashes_EventHashesAreNull() + { + var watched = new List { MakeWatchedSchema(SchemaHex, SeedHex) }; + var transform = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + + var result = CESParser.GetEvents(new List { transform }, watched); + + Assert.IsNull(result[0].ContractHash); + Assert.IsNull(result[0].ContractPackageHash); + } + + [Test] + public void GetEvents_TwoDifferentContracts_EventsCarryCorrectHashes() + { + const string hash1 = "hash-aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd"; + const string hash2 = "hash-1122334411223344112233441122334411223344112233441122334411223344"; + + var schema1 = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)).Events) + { ContractHash = hash1, EventsURef = new URef($"uref-{SeedHex}-000") }; + var schema2 = new CESContractSchema(CESContractSchema.ParseSchema(Hex.Decode(SchemaHex2)).Events) + { ContractHash = hash2, EventsURef = new URef($"uref-{SeedHex2}-000") }; + + var t1 = MakeCESTransform(DictKeyHex, Hex.Decode(EventHex), Hex.Decode(SeedHex)); + var t2 = MakeCESTransform(DictKeyHex2, Hex.Decode(EventMintHex), Hex.Decode(SeedHex2)); + var watched = new List { schema1, schema2 }; + + var result = CESParser.GetEvents(new List { t1, t2 }, watched); + + Assert.AreEqual(2, result.Count); + Assert.AreEqual(hash1, result[0].ContractHash); + Assert.AreEqual(hash2, result[1].ContractHash); + } + + // ─── ParseEvent in isolation ─────────────────────────────────────────── + + [Test] + public void ParseEvent_InIsolation_TransformIdIsZeroAndEventIdIsNull() + { + var schema = ParseSchema(SchemaHex); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + + Assert.AreEqual(0, evt.TransformId); + Assert.IsNull(evt.EventId); + } + + [Test] + public void ParseEvent_WithExplicitContext_TransformIdAndEventIdAreSet() + { + var schema = ParseSchema(SchemaHex); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema, transformId: 5, eventId: "99"); + + Assert.AreEqual(5, evt.TransformId); + Assert.AreEqual("99", evt.EventId); + } + } +} diff --git a/Casper.Network.SDK.Test/CESParserTest.cs b/Casper.Network.SDK.Test/CESParserTest.cs index c3102fb..c80267e 100644 --- a/Casper.Network.SDK.Test/CESParserTest.cs +++ b/Casper.Network.SDK.Test/CESParserTest.cs @@ -43,7 +43,7 @@ public class CESParserTest [Test] public void ParseSchema_SingleEvent_CorrectEventName() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); Assert.IsTrue(schema.TryGetEventSchema("Transfer", out var eventSchema)); Assert.AreEqual("Transfer", eventSchema.EventName); @@ -52,7 +52,7 @@ public void ParseSchema_SingleEvent_CorrectEventName() [Test] public void ParseSchema_SingleEvent_CorrectFieldCount() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); schema.TryGetEventSchema("Transfer", out var eventSchema); Assert.AreEqual(2, eventSchema.Fields.Count); @@ -61,7 +61,7 @@ public void ParseSchema_SingleEvent_CorrectFieldCount() [Test] public void ParseSchema_SingleEvent_CorrectFieldDefinitions() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); schema.TryGetEventSchema("Transfer", out var eventSchema); @@ -75,7 +75,7 @@ public void ParseSchema_SingleEvent_CorrectFieldDefinitions() [Test] public void ParseSchema_UnknownEvent_ReturnsFalse() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); Assert.IsFalse(schema.TryGetEventSchema("Mint", out _)); } @@ -83,8 +83,8 @@ public void ParseSchema_UnknownEvent_ReturnsFalse() [Test] public void ParseEvent_CorrectEventName() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); - var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); Assert.AreEqual("event_Transfer", evt.Name); } @@ -92,8 +92,8 @@ public void ParseEvent_CorrectEventName() [Test] public void ParseEvent_CorrectFieldCount() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); - var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); Assert.AreEqual(2, evt.Fields.Count); } @@ -101,8 +101,8 @@ public void ParseEvent_CorrectFieldCount() [Test] public void ParseEvent_U512FieldCorrectValue() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); - var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); var amountField = evt["amount"]; Assert.IsNotNull(amountField); @@ -113,8 +113,8 @@ public void ParseEvent_U512FieldCorrectValue() [Test] public void ParseEvent_StringFieldCorrectValue() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); - var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); var senderField = evt["sender"]; Assert.IsNotNull(senderField); @@ -125,8 +125,8 @@ public void ParseEvent_StringFieldCorrectValue() [Test] public void ParseEvent_IndexerReturnsNullForMissingField() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); - var evt = CESParser.ParseEvent(Hex.Decode(EventHex), schema); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); Assert.IsNull(evt["nonexistent"]); } @@ -134,7 +134,7 @@ public void ParseEvent_IndexerReturnsNullForMissingField() [Test] public void ParseEvent_UnknownEventName_ThrowsKeyNotFoundException() { - var schema = CESParser.ParseSchema(Hex.Decode(SchemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); // Build event bytes with an event name ("event_Mint") not present in the schema. var mintEventHex = string.Concat( @@ -143,7 +143,7 @@ public void ParseEvent_UnknownEventName_ThrowsKeyNotFoundException() ); Assert.Throws( - () => CESParser.ParseEvent(Hex.Decode(mintEventHex), schema)); + () => CESEvent.ParseEvent(Hex.Decode(mintEventHex), schema)); } [Test] @@ -162,7 +162,7 @@ public void ParseSchema_OptionType_CorrectInnerType() "16" // CLType.PublicKey ); - var schema = CESParser.ParseSchema(Hex.Decode(schemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); schema.TryGetEventSchema("Approve", out var eventSchema); Assert.AreEqual("operator", eventSchema.Fields[0].Name); @@ -189,7 +189,7 @@ public void ParseSchema_MapType_CorrectKeyAndValueTypes() "07" // value: CLType.U256 ); - var schema = CESParser.ParseSchema(Hex.Decode(schemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); schema.TryGetEventSchema("Allowances", out var eventSchema); Assert.AreEqual("data", eventSchema.Fields[0].Name); @@ -215,7 +215,7 @@ public void ParseSchema_TupleType_CorrectInnerTypes() "66657246726f6d04000000070000007370656e6465720b050000006f776e65720b09000000726563697069656e740b06" + "000000616d6f756e7407"; - var schema = CESParser.ParseSchema(Hex.Decode(schemaHex)); + var schema = CESContractSchema.ParseSchema(Hex.Decode(schemaHex)); Assert.IsNotNull(schema); schema.TryGetEventSchema("Transfer", out var eventSchema); Assert.IsNotNull(eventSchema); @@ -223,11 +223,11 @@ public void ParseSchema_TupleType_CorrectInnerTypes() var evt0 = "0a0000006576656e745f4d696e74011262d06e53125ea098187fb4d1d5b10a7afed48e5e5eef182ed992fc5b10034908000064a7b3b6e00d"; - var parsedEvt = CESParser.ParseEvent(Hex.Decode(evt0), schema); + var parsedEvt = CESEvent.ParseEvent(Hex.Decode(evt0), schema); Assert.IsNotNull(parsedEvt); Assert.AreEqual("event_Mint", parsedEvt.Name); - var amount = parsedEvt["amount"]; - amount.Value + // var amount = parsedEvt["amount"]; + // amount.Value } } } diff --git a/Casper.Network.SDK.Test/CLValueReaderTest.cs b/Casper.Network.SDK.Test/CLValueReaderTest.cs new file mode 100644 index 0000000..d0467ba --- /dev/null +++ b/Casper.Network.SDK.Test/CLValueReaderTest.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using Casper.Network.SDK.ByteSerializers; +using Casper.Network.SDK.Types; +using NUnit.Framework; +using Org.BouncyCastle.Utilities.Encoders; + +namespace NetCasperTest +{ + public class CLValueReaderTest + { + // ─── helpers ─────────────────────────────────────────────────────────────── + + private static CLValueReader ReaderFor(byte[] bytes) => + new CLValueReader(new BinaryReader(new MemoryStream(bytes))); + + // ─── constructor guard ───────────────────────────────────────────────────── + + [Test] + public void NullReaderThrowsArgumentNullException() + { + Assert.Throws(() => new CLValueReader(null)); + } + + [Test] + public void NullTypeInfoThrowsArgumentNullException() + { + var reader = ReaderFor(Array.Empty()); + Assert.Throws(() => reader.Read(null)); + } + + // ─── primitives ──────────────────────────────────────────────────────────── + + [Test] + public void ReadBoolTest() + { + foreach (var value in new[] { false, true }) + { + var original = CLValue.Bool(value); + var result = ReaderFor(original.Bytes).Read(CLType.Bool); + + Assert.AreEqual(CLType.Bool, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToBoolean()); + } + } + + [Test] + public void ReadI32Test() + { + foreach (var value in new[] { int.MinValue, -10, 0, 42, int.MaxValue }) + { + var original = CLValue.I32(value); + var result = ReaderFor(original.Bytes).Read(CLType.I32); + + Assert.AreEqual(CLType.I32, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToInt32()); + } + } + + [Test] + public void ReadI64Test() + { + foreach (var value in new[] { long.MinValue, -16L, 0L, 1L, long.MaxValue }) + { + var original = CLValue.I64(value); + var result = ReaderFor(original.Bytes).Read(CLType.I64); + + Assert.AreEqual(CLType.I64, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToInt64()); + } + } + + [Test] + public void ReadU8Test() + { + foreach (var value in new byte[] { 0x00, 0x7F, 0xFF }) + { + var original = CLValue.U8(value); + var result = ReaderFor(original.Bytes).Read(CLType.U8); + + Assert.AreEqual(CLType.U8, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToByte()); + } + } + + [Test] + public void ReadU32Test() + { + foreach (var value in new uint[] { 0, 1, uint.MaxValue }) + { + var original = CLValue.U32(value); + var result = ReaderFor(original.Bytes).Read(CLType.U32); + + Assert.AreEqual(CLType.U32, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToUInt32()); + } + } + + [Test] + public void ReadU64Test() + { + foreach (var value in new ulong[] { 0, 1, ulong.MaxValue }) + { + var original = CLValue.U64(value); + var result = ReaderFor(original.Bytes).Read(CLType.U64); + + Assert.AreEqual(CLType.U64, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToUInt64()); + } + } + + [Test] + public void ReadU128Test() + { + // single-byte value + var small = new BigInteger(255); + var original = CLValue.U128(small); + var result = ReaderFor(original.Bytes).Read(CLType.U128); + Assert.AreEqual(CLType.U128, result.TypeInfo.Type); + Assert.AreEqual(small, result.ToBigInteger()); + + // ulong.MaxValue — multi-byte encoding + var large = new BigInteger(ulong.MaxValue); + original = CLValue.U128(large); + result = ReaderFor(original.Bytes).Read(CLType.U128); + Assert.AreEqual(CLType.U128, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(large, result.ToBigInteger()); + } + + [Test] + public void ReadU256Test() + { + var value = new BigInteger(ulong.MaxValue); + var original = CLValue.U256(value); + var result = ReaderFor(original.Bytes).Read(CLType.U256); + + Assert.AreEqual(CLType.U256, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToBigInteger()); + } + + [Test] + public void ReadU512Test() + { + var value = new BigInteger(ulong.MaxValue); + var original = CLValue.U512(value); + var result = ReaderFor(original.Bytes).Read(CLType.U512); + + Assert.AreEqual(CLType.U512, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToBigInteger()); + } + + [Test] + public void ReadUnitTest() + { + var original = CLValue.Unit(); + var result = ReaderFor(original.Bytes).Read(CLType.Unit); + + Assert.AreEqual(CLType.Unit, result.TypeInfo.Type); + Assert.AreEqual(0, result.Bytes.Length); + } + + // ─── string ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadStringTest() + { + var value = "Hello, Casper!"; + var original = CLValue.String(value); + var result = ReaderFor(original.Bytes).Read(CLType.String); + + Assert.AreEqual(CLType.String, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(value, result.ToString()); + } + + [Test] + public void ReadEmptyStringTest() + { + var original = CLValue.String(string.Empty); + var result = ReaderFor(original.Bytes).Read(CLType.String); + + Assert.AreEqual(CLType.String, result.TypeInfo.Type); + Assert.AreEqual(string.Empty, result.ToString()); + } + + // ─── URef ────────────────────────────────────────────────────────────────── + + [Test] + public void ReadURefTest() + { + var urefStr = "uref-000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f-007"; + var original = CLValue.URef(urefStr); + var result = ReaderFor(original.Bytes).Read(CLType.URef); + + Assert.AreEqual(CLType.URef, result.TypeInfo.Type); + Assert.AreEqual(33, result.Bytes.Length); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(urefStr, result.ToURef().ToString()); + } + + // ─── PublicKey ───────────────────────────────────────────────────────────── + + [Test] + public void ReadPublicKeyEd25519Test() + { + var publicKey = KeyPair.CreateNew(KeyAlgo.ED25519).PublicKey; + var original = CLValue.PublicKey(publicKey); + var result = ReaderFor(original.Bytes).Read(CLType.PublicKey); + + Assert.AreEqual(CLType.PublicKey, result.TypeInfo.Type); + Assert.AreEqual(33, result.Bytes.Length); // 1 algo + 32 key bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(publicKey.ToAccountHex(), result.ToPublicKey().ToAccountHex()); + } + + [Test] + public void ReadPublicKeySecp256k1Test() + { + var publicKey = KeyPair.CreateNew(KeyAlgo.SECP256K1).PublicKey; + var original = CLValue.PublicKey(publicKey); + var result = ReaderFor(original.Bytes).Read(CLType.PublicKey); + + Assert.AreEqual(CLType.PublicKey, result.TypeInfo.Type); + Assert.AreEqual(34, result.Bytes.Length); // 1 algo + 33 key bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(publicKey.ToAccountHex(), result.ToPublicKey().ToAccountHex()); + } + + // ─── Key (GlobalStateKey) ────────────────────────────────────────────────── + + [Test] + public void ReadKeyAccountHashTest() + { + var key = GlobalStateKey.FromString( + "account-hash-989ca079a5e446071866331468ab949483162588d57ec13ba6bb051f1e15f8b7"); + var original = CLValue.Key(key); + var typeInfo = new CLKeyTypeInfo(KeyIdentifier.Account); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Key, result.TypeInfo.Type); + Assert.AreEqual(33, result.Bytes.Length); // 1 tag + 32 hash bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(key.ToString(), result.ToGlobalStateKey().ToString()); + } + + [Test] + public void ReadKeyEraInfoTest() + { + var key = GlobalStateKey.FromString("era-2034"); + var original = CLValue.Key(key); + var typeInfo = new CLKeyTypeInfo(KeyIdentifier.EraInfo); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Key, result.TypeInfo.Type); + Assert.AreEqual(9, result.Bytes.Length); // 1 tag + 8 u64 bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(key.ToString(), result.ToGlobalStateKey().ToString()); + } + + [Test] + public void ReadKeyURefTest() + { + var key = GlobalStateKey.FromString( + "uref-000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f-007"); + var original = CLValue.Key(key); + var typeInfo = new CLKeyTypeInfo(KeyIdentifier.URef); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Key, result.TypeInfo.Type); + Assert.AreEqual(34, result.Bytes.Length); // 1 tag + 33 URef bytes + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(key.ToString(), result.ToGlobalStateKey().ToString()); + } + + // ─── Option ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadOptionSomeTest() + { + var original = CLValue.Option(CLValue.U32(42u)); + var typeInfo = new CLOptionTypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLOptionTypeInfo)result.TypeInfo).OptionType.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.IsTrue(result.IsSome()); + Assert.AreEqual(0x01, result.Bytes[0]); // Some tag + // inner bytes after the tag should match U32(42).Bytes + var innerBytes = result.Bytes.Skip(1).ToArray(); + Assert.AreEqual(Hex.ToHexString(CLValue.U32(42u).Bytes), Hex.ToHexString(innerBytes)); + } + + [Test] + public void ReadOptionNoneTest() + { + var original = CLValue.OptionNone(CLType.U32); + var typeInfo = new CLOptionTypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLOptionTypeInfo)result.TypeInfo).OptionType.Type); + Assert.AreEqual(1, result.Bytes.Length); + Assert.AreEqual(0x00, result.Bytes[0]); // None tag + Assert.IsTrue(result.IsNone()); + } + + // ─── List ────────────────────────────────────────────────────────────────── + + [Test] + public void ReadListTest() + { + var original = CLValue.List(new[] + { + CLValue.U32(1), CLValue.U32(2), CLValue.U32(3), CLValue.U32(4) + }); + var typeInfo = new CLListTypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.List, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLListTypeInfo)result.TypeInfo).ListType.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + + var items = result.ToList(); + Assert.AreEqual(4, items.Count); + Assert.AreEqual(1u, items[0]); + Assert.AreEqual(2u, items[1]); + Assert.AreEqual(3u, items[2]); + Assert.AreEqual(4u, items[3]); + } + + [Test] + public void ReadEmptyListTest() + { + var original = CLValue.EmptyList(new CLKeyTypeInfo(KeyIdentifier.Hash)); + var typeInfo = new CLListTypeInfo(new CLKeyTypeInfo(KeyIdentifier.Hash)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.List, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0, result.ToList().Count); + } + + // ─── ByteArray ───────────────────────────────────────────────────────────── + + [Test] + public void ReadByteArrayTest() + { + var data = Hex.Decode("0102030405060708"); + var original = CLValue.ByteArray(data); + var typeInfo = new CLByteArrayTypeInfo(8); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.ByteArray, result.TypeInfo.Type); + Assert.AreEqual(8, result.Bytes.Length); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.IsTrue(data.SequenceEqual(result.ToByteArray())); + } + + // ─── Result ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadResultOkTest() + { + var original = CLValue.Ok(CLValue.U8(0xFF), new CLTypeInfo(CLType.String)); + var typeInfo = new CLResultTypeInfo(CLType.U8, CLType.String); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Result, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0x01, result.Bytes[0]); // Ok tag + var typed = result.ToResult(); + Assert.IsTrue(typed.Success); + Assert.AreEqual(0xFF, typed.Value); + } + + [Test] + public void ReadResultErrTest() + { + var original = CLValue.Err(CLValue.String("Error!"), new CLTypeInfo(CLType.Unit)); + var typeInfo = new CLResultTypeInfo(CLType.Unit, CLType.String); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Result, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0x00, result.Bytes[0]); // Err tag + var typed = result.ToResult(); + Assert.IsFalse(typed.Success); + Assert.AreEqual("Error!", typed.Error); + } + + // ─── Map ─────────────────────────────────────────────────────────────────── + + [Test] + public void ReadMapTest() + { + var original = CLValue.Map(new Dictionary + { + { CLValue.String("key1"), CLValue.U32(1) }, + { CLValue.String("key2"), CLValue.U32(2) } + }); + var typeInfo = new CLMapTypeInfo(CLType.String, CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Map, result.TypeInfo.Type); + var mapType = (CLMapTypeInfo)result.TypeInfo; + Assert.AreEqual(CLType.String, mapType.KeyType.Type); + Assert.AreEqual(CLType.U32, mapType.ValueType.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + + var dict = result.ToDictionary(); + Assert.AreEqual(2, dict.Count); + Assert.AreEqual(1u, dict["key1"]); + Assert.AreEqual(2u, dict["key2"]); + } + + [Test] + public void ReadEmptyMapTest() + { + var original = CLValue.EmptyMap(CLType.String, new CLKeyTypeInfo(KeyIdentifier.Hash)); + var typeInfo = new CLMapTypeInfo(CLType.String, new CLKeyTypeInfo(KeyIdentifier.Hash)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Map, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(0, result.ToDictionary().Count); + } + + // ─── Tuples ──────────────────────────────────────────────────────────────── + + [Test] + public void ReadTuple1Test() + { + var original = CLValue.Tuple1(CLValue.U32(17)); + var typeInfo = new CLTuple1TypeInfo(CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple1, result.TypeInfo.Type); + Assert.AreEqual(CLType.U32, ((CLTuple1TypeInfo)result.TypeInfo).Type0.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.AreEqual(17u, result.ToTuple1().Item1); + } + + [Test] + public void ReadTuple2Test() + { + var original = CLValue.Tuple2(CLValue.U32(17), CLValue.U32(127)); + var typeInfo = new CLTuple2TypeInfo(CLType.U32, CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple2, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple2(); + Assert.AreEqual(17u, t.Item1); + Assert.AreEqual(127u, t.Item2); + } + + [Test] + public void ReadTuple2MixedTypesTest() + { + var original = CLValue.Tuple2(CLValue.U32(127), CLValue.String("ABCDE")); + var typeInfo = new CLTuple2TypeInfo(CLType.U32, CLType.String); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple2, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple2(); + Assert.AreEqual(127u, t.Item1); + Assert.AreEqual("ABCDE", t.Item2); + } + + [Test] + public void ReadTuple3Test() + { + var original = CLValue.Tuple3(CLValue.U32(17), CLValue.U32(127), CLValue.U32(255)); + var typeInfo = new CLTuple3TypeInfo(CLType.U32, CLType.U32, CLType.U32); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple3, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple3(); + Assert.AreEqual(17u, t.Item1); + Assert.AreEqual(127u, t.Item2); + Assert.AreEqual(255u, t.Item3); + } + + // ─── nested / compound types ─────────────────────────────────────────────── + + [Test] + public void ReadOptionSomeListTest() + { + // Option(Some(List(U32))) + var inner = CLValue.List(new[] { CLValue.U32(10), CLValue.U32(20) }); + var original = CLValue.Option(inner); + var typeInfo = new CLOptionTypeInfo(new CLListTypeInfo(CLType.U32)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + Assert.IsTrue(result.IsSome()); + } + + [Test] + public void ReadOptionNoneListTest() + { + // Option(None) with inner type List(U32) + var original = CLValue.OptionNone(new CLListTypeInfo(CLType.U32)); + var typeInfo = new CLOptionTypeInfo(new CLListTypeInfo(CLType.U32)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Option, result.TypeInfo.Type); + Assert.IsTrue(result.IsNone()); + } + + [Test] + public void ReadListOfByteArraysTest() + { + // List(ByteArray(8)) + var p1 = Hex.Decode("0102030405060708"); + var p2 = Hex.Decode("090a0b0c0d0e0f00"); + var original = CLValue.List(new[] + { + CLValue.ByteArray(p1), + CLValue.ByteArray(p2) + }); + var typeInfo = new CLListTypeInfo(new CLByteArrayTypeInfo(8)); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.List, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + + var items = result.ToList(); + Assert.AreEqual(2, items.Count); + Assert.IsTrue(p1.SequenceEqual(items[0])); + Assert.IsTrue(p2.SequenceEqual(items[1])); + } + + [Test] + public void ReadTuple3MixedComplexTypesTest() + { + // Tuple3(I32, Bool, URef) + var uref = "uref-cdd5422295f6a61e86a4d3229b28dac2e67523c41e2aafed3a041362df7a8432-007"; + var original = CLValue.Tuple3( + CLValue.I32(123), + CLValue.Bool(true), + CLValue.URef(uref)); + var typeInfo = new CLTuple3TypeInfo(CLType.I32, CLType.Bool, CLType.URef); + var result = ReaderFor(original.Bytes).Read(typeInfo); + + Assert.AreEqual(CLType.Tuple3, result.TypeInfo.Type); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes)); + var t = result.ToTuple3(); + Assert.AreEqual(123, t.Item1); + Assert.AreEqual(true, t.Item2); + Assert.AreEqual(uref, t.Item3.ToString()); + } + + // ─── stream position and multi-value reads ───────────────────────────────── + + [Test] + public void ReadSequentialValuesAdvancesStreamTest() + { + // Pack three values back-to-back into a single MemoryStream and verify + // that each Read() advances the position so the next call reads correctly. + var u32Bytes = CLValue.U32(42u).Bytes; + var boolBytes = CLValue.Bool(true).Bytes; + var strBytes = CLValue.String("Casper").Bytes; + + using var ms = new MemoryStream(); + ms.Write(u32Bytes, 0, u32Bytes.Length); + ms.Write(boolBytes, 0, boolBytes.Length); + ms.Write(strBytes, 0, strBytes.Length); + ms.Position = 0; + + var clValueReader = new CLValueReader(new BinaryReader(ms)); + + var v1 = clValueReader.Read(CLType.U32); + var v2 = clValueReader.Read(CLType.Bool); + var v3 = clValueReader.Read(CLType.String); + + // All bytes consumed — stream at end + Assert.AreEqual(ms.Length, ms.Position); + + Assert.AreEqual(42u, v1.ToUInt32()); + Assert.AreEqual(true, v2.ToBoolean()); + Assert.AreEqual("Casper", v3.ToString()); + } + + // ─── bytes match CLValue factory output ──────────────────────────────────── + + [Test] + public void ReadBytesMatchCLValueFactoryOutputTest() + { + // CLValueReader.Read() must produce exactly the same Bytes as the + // corresponding CLValue factory method — verified against known hex strings + // from CLValueByteSerializerTest (the data-only portion, no length/type prefix). + + var serializer = new CLValueByteSerializer(); + + void VerifyRoundTrip(CLValue original, CLTypeInfo typeInfo) + { + var result = ReaderFor(original.Bytes).Read(typeInfo); + Assert.AreEqual(Hex.ToHexString(original.Bytes), Hex.ToHexString(result.Bytes), + $"Bytes mismatch for CLType {typeInfo.Type}"); + // Also verify that the result can be re-serialized identically + var reEncoded = serializer.ToBytes(result); + var originalEncoded = serializer.ToBytes(original); + Assert.AreEqual(Hex.ToHexString(originalEncoded), Hex.ToHexString(reEncoded), + $"Re-serialized bytes mismatch for CLType {typeInfo.Type}"); + } + + VerifyRoundTrip(CLValue.Bool(false), CLType.Bool); + VerifyRoundTrip(CLValue.I32(-10), CLType.I32); + VerifyRoundTrip(CLValue.I64(-16), CLType.I64); + VerifyRoundTrip(CLValue.U8(0x7F), CLType.U8); + VerifyRoundTrip(CLValue.U32(uint.MaxValue), CLType.U32); + VerifyRoundTrip(CLValue.U64(ulong.MaxValue), CLType.U64); + VerifyRoundTrip(CLValue.U128(new BigInteger(ulong.MaxValue)), CLType.U128); + VerifyRoundTrip(CLValue.U256(new BigInteger(ulong.MaxValue)), CLType.U256); + VerifyRoundTrip(CLValue.U512(new BigInteger(ulong.MaxValue)), CLType.U512); + VerifyRoundTrip(CLValue.Unit(), CLType.Unit); + VerifyRoundTrip(CLValue.String("Hello, Casper!"), CLType.String); + VerifyRoundTrip(CLValue.URef("uref-000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f-007"), + CLType.URef); + VerifyRoundTrip(CLValue.List(new[] { CLValue.U32(1), CLValue.U32(2) }), + new CLListTypeInfo(CLType.U32)); + VerifyRoundTrip(CLValue.ByteArray(Hex.Decode("0102030405060708")), + new CLByteArrayTypeInfo(8)); + VerifyRoundTrip(CLValue.Ok(CLValue.U8(0xFF), new CLTypeInfo(CLType.String)), + new CLResultTypeInfo(CLType.U8, CLType.String)); + VerifyRoundTrip(CLValue.Tuple1(CLValue.U32(17)), new CLTuple1TypeInfo(CLType.U32)); + VerifyRoundTrip(CLValue.Tuple2(CLValue.U32(17), CLValue.U32(127)), + new CLTuple2TypeInfo(CLType.U32, CLType.U32)); + VerifyRoundTrip(CLValue.Tuple3(CLValue.U32(17), CLValue.U32(127), CLValue.U32(17)), + new CLTuple3TypeInfo(CLType.U32, CLType.U32, CLType.U32)); + } + } +} diff --git a/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs b/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs index d4fc8eb..d4577a0 100644 --- a/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs +++ b/Casper.Network.SDK/ByteSerializers/CLValueByteSerializer.cs @@ -39,7 +39,7 @@ public CLValue FromReader(BinaryReader reader) var dataBytes = reader.ReadBytes((int)dataLength); // read type info recursively - var typeInfo = CLTypeFromBytes(reader); + var typeInfo = reader.ReadCLTypeInfo(); return new CLValue(dataBytes, typeInfo); } diff --git a/Casper.Network.SDK/CES/CESEvent.cs b/Casper.Network.SDK/CES/CESEvent.cs index 9a11075..1a6ba8d 100644 --- a/Casper.Network.SDK/CES/CESEvent.cs +++ b/Casper.Network.SDK/CES/CESEvent.cs @@ -1,79 +1,61 @@ +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; using Casper.Network.SDK.Types; +using Casper.Network.SDK.Utils; namespace Casper.Network.SDK.CES { /// - /// Defines one field in a CES event schema: its name and Casper type. + /// A fully parsed CES event containing the event name, its typed fields, and the + /// execution-context metadata set by . /// - public class CESEventSchemaField + public class CESEvent { + [JsonPropertyName("name")] public string Name { get; } - public CLTypeInfo CLTypeInfo { get; } - - public CESEventSchemaField(string name, CLTypeInfo clTypeInfo) - { - Name = name; - CLTypeInfo = clTypeInfo; - } - } - /// - /// Schema for a single named CES event type, listing its fields in order. - /// - public class CESEventSchema - { - public string EventName { get; } - public IReadOnlyList Fields { get; } - - public CESEventSchema(string eventName, IReadOnlyList fields) - { - EventName = eventName; - Fields = fields; - } - } + [JsonPropertyName("fields")] + [JsonConverter(typeof(NamedArgListConverter))] + public IReadOnlyList Fields { get; } - /// - /// Full schema for a CES-compliant contract, parsed from the __events_schema named key. - /// Maps event names to their . - /// - public class CESContractSchema - { - public IReadOnlyDictionary Events { get; } + /// + /// The contract hash of the emitting contract (e.g. "hash-abc…def"), + /// propagated from the supplied to the parser. + /// null when the schema was not annotated with a contract hash. + /// + [JsonPropertyName("contract_hash")] + public string ContractHash { get; init; } - public CESContractSchema(IReadOnlyDictionary events) - { - Events = events; - } - /// - /// Retrieves the schema for the given event name. + /// The contract-package hash of the emitting contract, + /// propagated from the supplied to the parser. + /// null when the schema was not annotated with a contract-package hash. /// - public CESEventSchema GetEventSchema(string eventName) - { - if (!TryGetEventSchema(eventName, out var schema)) - throw new KeyNotFoundException($"Event '{eventName}' not found in the contract schema."); - return schema; - } + [JsonPropertyName("contract_package_hash")] + public string ContractPackageHash { get; init; } /// - /// Tries to retrieve the schema for the given event name. + /// Zero-based index of the inside the execution-result + /// effect list from which this event was extracted. + /// Set by ; defaults to 0 when the event + /// is parsed in isolation via . /// - public bool TryGetEventSchema(string eventName, out CESEventSchema schema) - { - return Events.TryGetValue(eventName, out schema); - } - } + [JsonPropertyName("transform_id")] + public int TransformId { get; init; } - /// - /// A fully parsed CES event containing the event name and its typed fields. - /// - public class CESEvent - { - public string Name { get; } - public IReadOnlyList Fields { get; } + /// + /// The string key that identifies this entry inside the contract's __events + /// dictionary (i.e. the sequential event counter emitted by the contract). + /// Set by ; null when parsed in isolation. + /// + [JsonPropertyName("event_id")] + public string EventId { get; init; } + [JsonConstructor] public CESEvent(string name, IReadOnlyList fields) { Name = name; @@ -85,5 +67,135 @@ public CESEvent(string name, IReadOnlyList fields) /// public NamedArg this[string fieldName] => Fields.FirstOrDefault(f => f.Name == fieldName); + + // ───────────────────────────────────────────────────────────────────── + // Parse + // ───────────────────────────────────────────────────────────────────── + + /// + /// Parses a single CES event from its raw bytes using the given contract schema. + /// + /// + /// The raw bytes of one event entry (value) from the __events CLValue map. + /// + /// + /// The contract schema obtained from . + /// + /// + /// Zero-based index of the source in the execution-result + /// effect list. Used to populate . + /// Pass 0 (the default) when parsing in isolation. + /// + /// + /// The string key that identifies this entry in the contract's __events + /// dictionary. Used to populate . + /// Pass null (the default) when parsing in isolation. + /// + /// + /// Thrown when the event name found in is not present in + /// . + /// + public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema, + int transformId = 0, string eventId = null) + { + // Some CES implementations wrap the event payload in a Casper Vec (Bytes), + // which prepends a u32 LE length equal to the remaining byte count. Detect and + // skip that outer wrapper transparently. + var offset = 0; + if (rawBytes.Length >= 4) + { + var declaredLen = BitConverter.ToUInt32(rawBytes, 0); + if (!BitConverter.IsLittleEndian) + declaredLen = SwapU32(declaredLen); + if (declaredLen == rawBytes.Length - 4) + offset = 4; + } + + using var ms = new MemoryStream(rawBytes, offset, rawBytes.Length - offset); + using var reader = new BinaryReader(ms); + + var rawName = reader.ReadCLString(); + + // Strip the "event_" prefix that some CES implementations prepend to event names. + const string prefix = "event_"; + var eventName = rawName.StartsWith(prefix) + ? rawName.Substring(prefix.Length) + : rawName; + + if (!schema.TryGetEventSchema(eventName, out var eventSchema)) + throw new KeyNotFoundException( + $"Event '{rawName}' not found in the contract schema."); + + var fields = new List(eventSchema.Fields.Count); + var clValueReader = new CLValueReader(reader); + + foreach (var schemaField in eventSchema.Fields) + { + var clValue = clValueReader.Read(schemaField.CLTypeInfo); + fields.Add(new NamedArg(schemaField.Name, clValue)); + } + + return new CESEvent(eventName, fields) + { + ContractHash = schema.ContractHash, + ContractPackageHash = schema.ContractPackageHash, + TransformId = transformId, + EventId = eventId, + }; + } + + private static uint SwapU32(uint value) => + ((value & 0x000000FFu) << 24) | + ((value & 0x0000FF00u) << 8) | + ((value & 0x00FF0000u) >> 8) | + ((value & 0xFF000000u) >> 24); + + // ───────────────────────────────────────────────────────────────────── + // JSON converter for IReadOnlyList + // + // Each NamedArg is serialised by NamedArg.NamedArgConverter as the + // two-element JSON array ["fieldName", {CLValue}], so the field list + // becomes an array of such pairs: + // [["amount", {...}], ["sender", {...}], ...] + // ───────────────────────────────────────────────────────────────────── + + public class NamedArgListConverter : JsonConverter> + { + private static readonly NamedArg.NamedArgConverter _itemConverter = + new NamedArg.NamedArgConverter(); + + public override IReadOnlyList Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Expected start of array for CESEvent fields."); + + reader.Read(); // move past the outer '[' to the first element (or ']') + + var list = new List(); + while (reader.TokenType != JsonTokenType.EndArray) + { + // Each element is itself a two-element array ["name", {CLValue}]; + // NamedArgConverter.Read expects the reader to be at StartArray. + list.Add(_itemConverter.Read(ref reader, typeof(NamedArg), options)); + reader.Read(); // advance past the EndArray of the just-read NamedArg + } + + return list; + } + + public override void Write( + Utf8JsonWriter writer, + IReadOnlyList value, + JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var arg in value) + _itemConverter.Write(writer, arg, options); + writer.WriteEndArray(); + } + } } } diff --git a/Casper.Network.SDK/CES/CESParser.cs b/Casper.Network.SDK/CES/CESParser.cs index ebd3443..c426158 100644 --- a/Casper.Network.SDK/CES/CESParser.cs +++ b/Casper.Network.SDK/CES/CESParser.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Linq; using Casper.Network.SDK.Types; -using Casper.Network.SDK.Utils; namespace Casper.Network.SDK.CES { @@ -18,217 +17,79 @@ namespace Casper.Network.SDK.CES public static class CESParser { /// - /// Parses the raw bytes of the __events_schema CLValue (type Any) - /// into a . + /// Scans a list of execution-result transforms and returns every CES event emitted by + /// any of the watched contracts. /// - /// - /// The Bytes property of the CLValue retrieved from __events_schema. + /// + /// The Effect list from an (V1 or V2). /// - public static CESContractSchema ParseSchema(byte[] rawBytes) - { - using var ms = new MemoryStream(rawBytes); - using var reader = new BinaryReader(ms); - - var numEvents = reader.ReadInt32(); - var events = new Dictionary(numEvents); - - for (int i = 0; i < numEvents; i++) - { - var eventName = reader.ReadCLString(); - - var numFields = reader.ReadInt32(); - var fields = new List(numFields); - - for (int j = 0; j < numFields; j++) - { - var fieldName = reader.ReadCLString(); - var fieldType = ReadCLTypeInfo(reader); - fields.Add(new CESEventSchemaField(fieldName, fieldType)); - } - - events[eventName] = new CESEventSchema(eventName, fields); - } - - return new CESContractSchema(events); - } - - /// - /// Parses a single CES event from its raw bytes using the given contract schema. - /// - /// - /// The raw bytes of one event entry (value) from the __events CLValue map. - /// - /// - /// The contract schema obtained from . + /// + /// The list of instances to watch. + /// Each schema must have set; schemas + /// where it is null are silently skipped during matching. /// - /// - /// Thrown when the event name found in is not present in - /// . - /// - public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema) + /// + /// Ordered list of instances found, in the same order as they + /// appear in . Never null; empty when no events are found. + /// + public static List GetEvents( + List transforms, + IReadOnlyList watchedContracts) { - // Some CES implementations wrap the event payload in a Casper Vec (Bytes), - // which prepends a u32 LE length equal to the remaining byte count. Detect and - // skip that outer wrapper transparently. - var offset = 0; - if (rawBytes.Length >= 4) - { - var declaredLen = BitConverter.ToUInt32(rawBytes, 0); - if (!BitConverter.IsLittleEndian) - declaredLen = SwapU32(declaredLen); - if (declaredLen == rawBytes.Length - 4) - offset = 4; - } - - using var ms = new MemoryStream(rawBytes, offset, rawBytes.Length - offset); - using var reader = new BinaryReader(ms); - - var rawName = reader.ReadCLString(); - - // Strip the "event_" prefix that some CES implementations prepend to event names. - const string prefix = "event_"; - var eventName = rawName.StartsWith(prefix) - ? rawName.Substring(prefix.Length) - : rawName; - - if (!schema.TryGetEventSchema(eventName, out var eventSchema)) - throw new KeyNotFoundException( - $"Event '{rawName}' not found in the contract schema."); + if (transforms == null) + throw new ArgumentNullException(nameof(transforms)); + if (watchedContracts == null) + throw new ArgumentNullException(nameof(watchedContracts)); - var fields = new List(eventSchema.Fields.Count); + var results = new List(); - foreach (var schemaField in eventSchema.Fields) + for (int i = 0; i < transforms.Count; i++) { - var fwType = schemaField.CLTypeInfo.GetFrameworkType(); - var value = reader.ReadCLItem(schemaField.CLTypeInfo, fwType); - var clValue = BuildCLValue(value, schemaField.CLTypeInfo); - fields.Add(new NamedArg(schemaField.Name, clValue)); - } + var transform = transforms[i]; - return new CESEvent(rawName, fields); - } + // 1. Key must be a dictionary entry. + if (transform.Key is not DictionaryKey) + continue; - private static uint SwapU32(uint value) => - ((value & 0x000000FFu) << 24) | - ((value & 0x0000FF00u) << 8) | - ((value & 0x00FF0000u) >> 8) | - ((value & 0xFF000000u) >> 24); + // 2. Kind must be a CLValue write. + if (transform.Kind is not WriteTransformKind writeKind) + continue; - /// - /// Reads a from the binary stream. - /// Mirrors CLValueByteSerializer.CLTypeToBytes() in reverse. - /// - private static CLTypeInfo ReadCLTypeInfo(BinaryReader reader) - { - var tag = (CLType)reader.ReadByte(); + var clValue = writeKind.Value?.CLValue; + if (clValue == null) + continue; - return tag switch - { - CLType.Option => new CLOptionTypeInfo(ReadCLTypeInfo(reader)), - CLType.List => new CLListTypeInfo(ReadCLTypeInfo(reader)), - CLType.ByteArray => new CLByteArrayTypeInfo(reader.ReadInt32()), - CLType.Result => new CLResultTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), - CLType.Map => new CLMapTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), - CLType.Tuple1 => new CLTuple1TypeInfo(ReadCLTypeInfo(reader)), - CLType.Tuple2 => new CLTuple2TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), - CLType.Tuple3 => new CLTuple3TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), - _ => new CLTypeInfo(tag) - }; - } - - /// - /// Wraps a deserialized field value back into a so that callers - /// can use the standard CLValue.ToXxx() conversion methods on each field. - /// - private static CLValue BuildCLValue(object value, CLTypeInfo typeInfo) - { - return typeInfo.Type switch - { - CLType.Bool => CLValue.Bool((bool)value), - CLType.I32 => CLValue.I32((int)value), - CLType.I64 => CLValue.I64((long)value), - CLType.U8 => CLValue.U8((byte)value), - CLType.U32 => CLValue.U32((uint)value), - CLType.U64 => CLValue.U64((ulong)value), - CLType.U128 => CLValue.U128((System.Numerics.BigInteger)value), - CLType.U256 => CLValue.U256((System.Numerics.BigInteger)value), - CLType.U512 => CLValue.U512((System.Numerics.BigInteger)value), - CLType.Unit => CLValue.Unit(), - CLType.String => CLValue.String((string)value), - CLType.URef => CLValue.URef((URef)value), - CLType.PublicKey => CLValue.PublicKey((PublicKey)value), - CLType.Key => CLValue.Key((GlobalStateKey)value), - _ => new CLValue(SerializeValue(value, typeInfo), typeInfo, value) - }; - } - - /// - /// Re-serializes a complex CLValue (Option, List, Map, etc.) to bytes so it can be - /// stored inside a wrapper. Uses the SDK's existing serializer. - /// - private static byte[] SerializeValue(object value, CLTypeInfo typeInfo) - { - var serializer = new ByteSerializers.CLValueByteSerializer(); - // Build a temporary CLValue to get its inner bytes via the serializer. - // For complex types we need the raw data bytes, which are the first part of - // the full serialization (data_length + data_bytes + type_bytes). - using var ms = new MemoryStream(); - - switch (typeInfo) - { - case CLOptionTypeInfo optionType: + // 3. Parse the dictionary envelope. + CLValueDictionary dict; + try { - if (value == null) - { - ms.WriteByte(0x00); - } - else - { - ms.WriteByte(0x01); - var inner = BuildCLValue(value, optionType.OptionType); - ms.Write(inner.Bytes); - } - break; + dict = CLValueDictionary.Parse(clValue.Bytes); } - case CLListTypeInfo listType: + catch { - var list = (System.Collections.IList)value; - var lenBytes = BitConverter.GetBytes(list.Count); - if (!BitConverter.IsLittleEndian) Array.Reverse(lenBytes); - ms.Write(lenBytes); - foreach (var item in list) - { - var inner = BuildCLValue(item, listType.ListType); - ms.Write(inner.Bytes); - } - break; + // Not a CES dictionary entry — skip. + continue; } - case CLMapTypeInfo mapType: + + // 4. Match the seed address against the watched contracts. + var seedHex = dict.Seed.ToHexString(); + var schema = watchedContracts.FirstOrDefault(s => + s.EventsURef != null && s.EventsURef.ToHexString() == seedHex); + if (schema == null) + continue; + + // 5. Parse the event payload, stamping it with execution-result context. + try { - var dict = (System.Collections.IDictionary)value; - var lenBytes = BitConverter.GetBytes(dict.Count); - if (!BitConverter.IsLittleEndian) Array.Reverse(lenBytes); - ms.Write(lenBytes); - foreach (System.Collections.DictionaryEntry kv in dict) - { - var k = BuildCLValue(kv.Key, mapType.KeyType); - var v = BuildCLValue(kv.Value, mapType.ValueType); - ms.Write(k.Bytes); - ms.Write(v.Bytes); - } - break; + results.Add(CESEvent.ParseEvent(dict.Value, schema, i, dict.ItemKey)); } - case CLByteArrayTypeInfo baType: + catch (KeyNotFoundException) { - ms.Write((byte[])value); - break; + // Event name not present in this version of the schema — skip. } - default: - throw new NotSupportedException( - $"SerializeValue not implemented for CLType '{typeInfo.Type}'."); } - return ms.ToArray(); + return results; } } } diff --git a/Casper.Network.SDK/CES/CESSchema.cs b/Casper.Network.SDK/CES/CESSchema.cs new file mode 100644 index 0000000..da7ef26 --- /dev/null +++ b/Casper.Network.SDK/CES/CESSchema.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Casper.Network.SDK.Converters; +using Casper.Network.SDK.Types; +using Casper.Network.SDK.Utils; + +namespace Casper.Network.SDK.CES +{ + /// + /// Defines one field in a CES event schema: its name and Casper type. + /// + public class CESEventSchemaField + { + [JsonPropertyName("name")] + public string Name { get; } + + [JsonPropertyName("cl_type")] + [JsonConverter(typeof(CLTypeInfoConverter))] + public CLTypeInfo CLTypeInfo { get; } + + [JsonConstructor] + public CESEventSchemaField(string name, CLTypeInfo clTypeInfo) + { + Name = name; + CLTypeInfo = clTypeInfo; + } + } + + /// + /// Schema for a single named CES event type, listing its fields in order. + /// + public class CESEventSchema + { + [JsonPropertyName("event_name")] + public string EventName { get; } + + [JsonPropertyName("fields")] + public IReadOnlyList Fields { get; } + + [JsonConstructor] + public CESEventSchema(string eventName, IReadOnlyList fields) + { + EventName = eventName; + Fields = fields; + } + } + + /// + /// Full schema for a CES-compliant contract, parsed from the __events_schema named key. + /// Maps event names to their . + /// + public class CESContractSchema + { + [JsonPropertyName("events")] + public IReadOnlyDictionary Events { get; } + + /// + /// The contract hash (e.g. "hash-abc…def" or "entity-contract-abc…def") + /// that owns this schema. + /// Set by the caller after , or automatically + /// populated when the schema is fetched via . + /// + [JsonPropertyName("contract_hash")] + public string ContractHash { get; init; } + + /// + /// The contract-package hash supplied to (or set manually + /// after ). + /// + [JsonPropertyName("contract_package_hash")] + public string ContractPackageHash { get; init; } + + /// + /// The URef stored under the __events_schema named key of the contract. + /// Automatically populated by ; null when the schema + /// was obtained via directly. + /// + [JsonPropertyName("schema_uref")] + [JsonConverter(typeof(GlobalStateKey.GlobalStateKeyConverter))] + public URef SchemaURef { get; init; } + + /// + /// The URef stored under the __events named key of the contract. + /// This is the seed used to locate CES event entries in the global state transforms. + /// Automatically populated by ; null when the schema + /// was obtained via directly. + /// + [JsonPropertyName("events_uref")] + [JsonConverter(typeof(GlobalStateKey.GlobalStateKeyConverter))] + public URef EventsURef { get; init; } + + [JsonConstructor] + public CESContractSchema(IReadOnlyDictionary events) + { + Events = events; + } + + /// + /// Retrieves the schema for the given event name. + /// + /// + /// Thrown when is not present in this schema. + /// + public CESEventSchema GetEventSchema(string eventName) + { + if (!TryGetEventSchema(eventName, out var schema)) + throw new KeyNotFoundException($"Event '{eventName}' not found in the contract schema."); + return schema; + } + + /// + /// Tries to retrieve the schema for the given event name. + /// + public bool TryGetEventSchema(string eventName, out CESEventSchema schema) + { + return Events.TryGetValue(eventName, out schema); + } + + // ───────────────────────────────────────────────────────────────────── + // Parse + // ───────────────────────────────────────────────────────────────────── + + /// + /// Parses the raw bytes of the __events_schema CLValue (type Any) + /// into a . + /// + /// + /// The Bytes property of the CLValue retrieved from __events_schema. + /// + public static CESContractSchema ParseSchema(byte[] rawBytes) + { + using var ms = new MemoryStream(rawBytes); + using var reader = new BinaryReader(ms); + + var numEvents = reader.ReadInt32(); + var events = new Dictionary(numEvents); + + for (int i = 0; i < numEvents; i++) + { + var eventName = reader.ReadCLString(); + + var numFields = reader.ReadInt32(); + var fields = new List(numFields); + + for (int j = 0; j < numFields; j++) + { + var fieldName = reader.ReadCLString(); + var fieldType = reader.ReadCLTypeInfo(); + fields.Add(new CESEventSchemaField(fieldName, fieldType)); + } + + events[eventName] = new CESEventSchema(eventName, fields); + } + + return new CESContractSchema(events); + } + + // ───────────────────────────────────────────────────────────────────── + // Network factory + // ───────────────────────────────────────────────────────────────────── + + /// + /// Fetches and parses the CES event schema for a contract from the Casper network. + /// + /// + /// + /// The method calls GetPackage to enumerate all active versions of the contract + /// package and then resolves the target contract: + /// + /// + /// When is provided, that exact version is selected. + /// + /// When is null (the default), the highest-numbered + /// active (non-disabled) version is used. + /// + /// + /// + /// After resolving the contract the method fetches its named-key list to locate + /// the __events_schema and __events URefs, then queries the schema + /// CLValue directly from the __events_schema URef and parses it into a + /// . + /// The resulting schema has , , + /// , and automatically populated. + /// + /// + /// Both the legacy (ContractPackage) and the new Casper-2.x + /// (Package / entity) contract models are supported. + /// + /// + /// An active instance. + /// + /// The contract-package hash string, e.g. + /// "contract-package-hash-abc…def" or "package-abc…def". + /// + /// + /// The contract version number to load. Pass null (the default) to load + /// the latest active version. + /// + /// + /// A fully-populated with + /// and set. + /// + /// + /// Thrown when or is null. + /// + /// + /// Thrown when a specific was requested but not found in the + /// package. + /// + /// + /// Thrown when the package has no active versions, the RPC result is in an unexpected + /// format, or the __events_schema named key is absent. + /// + public static async Task LoadAsync( + ICasperClient client, + string contractPackageHash, + uint? version = null) + { + if (client == null) + throw new ArgumentNullException(nameof(client)); + if (string.IsNullOrWhiteSpace(contractPackageHash)) + throw new ArgumentNullException(nameof(contractPackageHash)); + + // ── 1. Fetch the package to enumerate versions ──────────────────── + var pkgResult = (await client.QueryGlobalState(contractPackageHash)).Parse(); + + // ── 2. Resolve the target contract version ──────────────────────── + string contractHash; + + if (pkgResult.StoredValue != null && + pkgResult.StoredValue.ContractPackage != null) + { + // Legacy node: Versions contains only active (non-disabled) entries. + var pkg = pkgResult.StoredValue.ContractPackage; + + ContractVersion chosen; + if (version.HasValue) + { + chosen = pkg.Versions.FirstOrDefault(v => v.Version == version.Value) + ?? throw new KeyNotFoundException( + $"Version {version.Value} not found in contract package '{contractPackageHash}'."); + } + else + { + chosen = pkg.Versions.OrderByDescending(v => v.Version).FirstOrDefault() + ?? throw new InvalidOperationException( + $"Contract package '{contractPackageHash}' has no active versions."); + } + + contractHash = chosen.Hash; // e.g. "contract-hash-abc…def" + } + else if (pkgResult.StoredValue != null && + pkgResult.StoredValue.Package != null) + { + // New node: Versions contains all versions; filter out disabled ones for latest. + var pkg = pkgResult.StoredValue.Package; + var disabledSet = new HashSet( + pkg.DisabledVersions?.Select(d => d.Version) ?? Enumerable.Empty()); + + EntityVersionAndHash chosen; + if (version.HasValue) + { + chosen = pkg.Versions.FirstOrDefault(v => v.EntityVersion.Version == version.Value) + ?? throw new KeyNotFoundException( + $"Version {version.Value} not found in contract package '{contractPackageHash}'."); + } + else + { + chosen = pkg.Versions + .Where(v => !disabledSet.Contains(v.EntityVersion.Version)) + .OrderByDescending(v => v.EntityVersion.Version) + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Contract package '{contractPackageHash}' has no active versions."); + } + + contractHash = chosen.AddressableEntity.ToString(); // e.g. "entity-contract-abc…def" + } + else + { + throw new InvalidOperationException( + $"GetPackage returned neither a ContractPackage nor a Package for '{contractPackageHash}'."); + } + + var contractKey = GlobalStateKey.FromString(contractHash); + + // ── 3. Fetch named keys to locate __events_schema and __events ──── + List namedKeys; + if (contractKey is AddressableEntityKey) + { + // New node (Casper 2.x): named keys are returned by state_get_entity. + var entityResult = (await client.GetEntity(contractHash)).Parse(); + namedKeys = entityResult.NamedKeys + ?? throw new InvalidOperationException( + $"No named keys returned for entity '{contractHash}'."); + } + else + { + // Legacy node: QueryGlobalState without a path returns the Contract + // object, which includes its named-key list. + var contractResult = (await client.QueryGlobalState(contractKey)).Parse(); + namedKeys = contractResult.StoredValue?.Contract?.NamedKeys + ?? throw new InvalidOperationException( + $"No named keys found for contract '{contractHash}'."); + } + + var schemaNamedKey = namedKeys.FirstOrDefault(k => k.Name == "__events_schema") + ?? throw new InvalidOperationException( + $"Named key '__events_schema' not found for contract '{contractHash}'."); + + var eventsNamedKey = namedKeys.FirstOrDefault(k => k.Name == "__events") + ?? throw new InvalidOperationException( + $"Named key '__events' not found for contract '{contractHash}'."); + + var schemaURef = schemaNamedKey.Key as URef + ?? throw new InvalidOperationException( + $"Expected a URef for '__events_schema' named key, got '{schemaNamedKey.Key?.GetType().Name}'."); + + var eventsURef = eventsNamedKey.Key as URef + ?? throw new InvalidOperationException( + $"Expected a URef for '__events' named key, got '{eventsNamedKey.Key?.GetType().Name}'."); + + // ── 4. Query the schema CLValue directly from its URef ──────────── + var schemaResult = (await client.QueryGlobalState(schemaURef)).Parse(); + + var clValue = schemaResult.StoredValue?.CLValue + ?? throw new InvalidOperationException( + $"No CLValue at '__events_schema' URef for contract '{contractHash}'."); + + // ── 5. Parse and annotate ───────────────────────────────────────── + var parsed = ParseSchema(clValue.Bytes); + return new CESContractSchema(parsed.Events) + { + ContractHash = contractKey.ToString(), + ContractPackageHash = contractPackageHash, + SchemaURef = schemaURef, + EventsURef = eventsURef, + }; + } + } +} diff --git a/Casper.Network.SDK/Types/CLValueReader.cs b/Casper.Network.SDK/Types/CLValueReader.cs new file mode 100644 index 0000000..d3113b4 --- /dev/null +++ b/Casper.Network.SDK/Types/CLValueReader.cs @@ -0,0 +1,233 @@ +using System; +using System.IO; + +namespace Casper.Network.SDK.Types +{ + /// + /// Reads a from a binary stream given a descriptor. + /// The caller is responsible for knowing the type layout of the stream; no type tag bytes are read. + /// + public class CLValueReader + { + private readonly BinaryReader _reader; + + public CLValueReader(BinaryReader reader) + { + _reader = reader ?? throw new ArgumentNullException(nameof(reader)); + } + + /// + /// Reads from the underlying stream the bytes that represent a value of the given type + /// and returns a constructed from those bytes and the provided type info. + /// + public CLValue Read(CLTypeInfo typeInfo) + { + if (typeInfo == null) + throw new ArgumentNullException(nameof(typeInfo)); + + using var ms = new MemoryStream(); + AppendValueBytes(ms, typeInfo); + return new CLValue(ms.ToArray(), typeInfo); + } + + private void AppendValueBytes(MemoryStream ms, CLTypeInfo typeInfo) + { + switch (typeInfo.Type) + { + case CLType.Bool: + case CLType.U8: + { + ms.WriteByte(_reader.ReadByte()); + break; + } + + case CLType.I32: + case CLType.U32: + { + var bytes = _reader.ReadBytes(4); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.I64: + case CLType.U64: + { + var bytes = _reader.ReadBytes(8); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.U128: + case CLType.U256: + case CLType.U512: + { + // length-prefixed little-endian big integer: 1 byte length + N bytes + var len = _reader.ReadByte(); + ms.WriteByte(len); + if (len > 0) + { + var bytes = _reader.ReadBytes(len); + ms.Write(bytes, 0, bytes.Length); + } + break; + } + + case CLType.Unit: + break; // zero bytes on the wire + + case CLType.String: + { + // 4-byte LE length prefix followed by UTF-8 bytes + var lenBytes = _reader.ReadBytes(4); + ms.Write(lenBytes, 0, lenBytes.Length); + var len = ToInt32LE(lenBytes); + if (len > 0) + { + var strBytes = _reader.ReadBytes(len); + ms.Write(strBytes, 0, strBytes.Length); + } + break; + } + + case CLType.URef: + { + // 32-byte key + 1-byte access rights + var bytes = _reader.ReadBytes(33); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.PublicKey: + { + // 1-byte algo prefix (already included in key size) + key bytes + int algo = _reader.PeekChar(); + var keyLen = algo == 0x01 + ? KeyAlgo.ED25519.GetKeySizeInBytes() + : KeyAlgo.SECP256K1.GetKeySizeInBytes(); + var bytes = _reader.ReadBytes(keyLen); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.Key: + { + // 1-byte tag + variant payload; mirrors GlobalStateKey.ReadCLGlobalStateKey + int keyId = _reader.PeekChar(); + int keyLen; + if (keyId == (char)KeyIdentifier.EraInfo) + keyLen = 9; // 1 tag + 8 (u64) + else if (keyId == (char)KeyIdentifier.URef) + keyLen = 34; // 1 tag + 33 (URef) + else + keyLen = 33; // 1 tag + 32 (hash) + var bytes = _reader.ReadBytes(keyLen); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.Option: + { + if (typeInfo is not CLOptionTypeInfo optionTypeInfo) + throw new Exception("Expected CLOptionTypeInfo for Option CLType."); + var tag = _reader.ReadByte(); + ms.WriteByte(tag); + if (tag != 0x00) // Some variant – read the inner value + AppendValueBytes(ms, optionTypeInfo.OptionType); + break; + } + + case CLType.List: + { + if (typeInfo is not CLListTypeInfo listTypeInfo) + throw new Exception("Expected CLListTypeInfo for List CLType."); + var countBytes = _reader.ReadBytes(4); + ms.Write(countBytes, 0, countBytes.Length); + var count = ToInt32LE(countBytes); + for (var i = 0; i < count; i++) + AppendValueBytes(ms, listTypeInfo.ListType); + break; + } + + case CLType.ByteArray: + { + if (typeInfo is not CLByteArrayTypeInfo baTypeInfo) + throw new Exception("Expected CLByteArrayTypeInfo for ByteArray CLType."); + var bytes = _reader.ReadBytes(baTypeInfo.Size); + ms.Write(bytes, 0, bytes.Length); + break; + } + + case CLType.Result: + { + if (typeInfo is not CLResultTypeInfo resultTypeInfo) + throw new Exception("Expected CLResultTypeInfo for Result CLType."); + var tag = _reader.ReadByte(); + ms.WriteByte(tag); + // 0x01 = Ok, 0x00 = Err + AppendValueBytes(ms, tag == 0x01 ? resultTypeInfo.Ok : resultTypeInfo.Err); + break; + } + + case CLType.Map: + { + if (typeInfo is not CLMapTypeInfo mapTypeInfo) + throw new Exception("Expected CLMapTypeInfo for Map CLType."); + var countBytes = _reader.ReadBytes(4); + ms.Write(countBytes, 0, countBytes.Length); + var count = ToInt32LE(countBytes); + for (var i = 0; i < count; i++) + { + AppendValueBytes(ms, mapTypeInfo.KeyType); + AppendValueBytes(ms, mapTypeInfo.ValueType); + } + break; + } + + case CLType.Tuple1: + { + if (typeInfo is not CLTuple1TypeInfo tuple1TypeInfo) + throw new Exception("Expected CLTuple1TypeInfo for Tuple1 CLType."); + AppendValueBytes(ms, tuple1TypeInfo.Type0); + break; + } + + case CLType.Tuple2: + { + if (typeInfo is not CLTuple2TypeInfo tuple2TypeInfo) + throw new Exception("Expected CLTuple2TypeInfo for Tuple2 CLType."); + AppendValueBytes(ms, tuple2TypeInfo.Type0); + AppendValueBytes(ms, tuple2TypeInfo.Type1); + break; + } + + case CLType.Tuple3: + { + if (typeInfo is not CLTuple3TypeInfo tuple3TypeInfo) + throw new Exception("Expected CLTuple3TypeInfo for Tuple3 CLType."); + AppendValueBytes(ms, tuple3TypeInfo.Type0); + AppendValueBytes(ms, tuple3TypeInfo.Type1); + AppendValueBytes(ms, tuple3TypeInfo.Type2); + break; + } + + default: + throw new Exception($"Unknown/unsupported CLType '{typeInfo.Type}'"); + } + } + + /// + /// Interprets four bytes as a little-endian signed 32-bit integer, regardless of host endianness. + /// + private static int ToInt32LE(byte[] bytes) + { + if (!BitConverter.IsLittleEndian) + { + var copy = new byte[4]; + Array.Copy(bytes, copy, 4); + Array.Reverse(copy); + return BitConverter.ToInt32(copy, 0); + } + return BitConverter.ToInt32(bytes, 0); + } + } +} diff --git a/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs b/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs index 1655fed..424f610 100644 --- a/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs +++ b/Casper.Network.SDK/Types/GlobalStateKey/GlobalStateKey.cs @@ -145,6 +145,9 @@ protected GlobalStateKey(string key, string keyPrefix) Key = keyPrefix + Hex.ToHexString(bytes); } + /// + /// Returns a string with the hex string representation of the key, without the permissions byte. + /// public string ToHexString() { return Hex.ToHexString(RawBytes); diff --git a/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs b/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs index 31e4033..235f318 100644 --- a/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs +++ b/Casper.Network.SDK/Utils/BinaryReaderExtensions.cs @@ -385,5 +385,26 @@ public static TItem ReadCLItem(BinaryReader reader, CLTypeInfo typeInfo) return (TItem) item; } + + /// + /// Reads a from the binary stream. + /// + public static CLTypeInfo ReadCLTypeInfo(this BinaryReader reader) + { + var tag = (CLType)reader.ReadByte(); + + return tag switch + { + CLType.Option => new CLOptionTypeInfo(ReadCLTypeInfo(reader)), + CLType.List => new CLListTypeInfo(ReadCLTypeInfo(reader)), + CLType.ByteArray => new CLByteArrayTypeInfo(reader.ReadInt32()), + CLType.Result => new CLResultTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Map => new CLMapTypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Tuple1 => new CLTuple1TypeInfo(ReadCLTypeInfo(reader)), + CLType.Tuple2 => new CLTuple2TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + CLType.Tuple3 => new CLTuple3TypeInfo(ReadCLTypeInfo(reader), ReadCLTypeInfo(reader), ReadCLTypeInfo(reader)), + _ => new CLTypeInfo(tag) + }; + } } } \ No newline at end of file From c9c1222effa58b52bae9092b78865e0e76e9f98e Mon Sep 17 00:00:00 2001 From: David Hernando Date: Tue, 3 Mar 2026 23:08:55 +0100 Subject: [PATCH 3/5] added CES Parser Signed-off-by: David Hernando --- Casper.Network.SDK.Test/CESParserTest.cs | 8 +- Casper.Network.SDK/CES/CESEvent.cs | 16 ++- Casper.Network.SDK/CES/CESParser.cs | 10 +- Docs/Articles/CasperEventStandard.md | 169 +++++++++++++++++------ Docs/Articles/toc.yml | 2 + 5 files changed, 151 insertions(+), 54 deletions(-) diff --git a/Casper.Network.SDK.Test/CESParserTest.cs b/Casper.Network.SDK.Test/CESParserTest.cs index c80267e..5a34814 100644 --- a/Casper.Network.SDK.Test/CESParserTest.cs +++ b/Casper.Network.SDK.Test/CESParserTest.cs @@ -106,8 +106,8 @@ public void ParseEvent_U512FieldCorrectValue() var amountField = evt["amount"]; Assert.IsNotNull(amountField); - Assert.AreEqual(CLType.U512, amountField.Value.TypeInfo.Type); - Assert.AreEqual(new BigInteger(100), amountField.Value.ToBigInteger()); + Assert.AreEqual(CLType.U512, amountField.TypeInfo.Type); + Assert.AreEqual(new BigInteger(100), amountField.ToBigInteger()); } [Test] @@ -118,8 +118,8 @@ public void ParseEvent_StringFieldCorrectValue() var senderField = evt["sender"]; Assert.IsNotNull(senderField); - Assert.AreEqual(CLType.String, senderField.Value.TypeInfo.Type); - Assert.AreEqual("Alice", senderField.Value.ToString()); + Assert.AreEqual(CLType.String, senderField.TypeInfo.Type); + Assert.AreEqual("Alice", senderField.ToString()); } [Test] diff --git a/Casper.Network.SDK/CES/CESEvent.cs b/Casper.Network.SDK/CES/CESEvent.cs index 1a6ba8d..f3bd205 100644 --- a/Casper.Network.SDK/CES/CESEvent.cs +++ b/Casper.Network.SDK/CES/CESEvent.cs @@ -38,6 +38,11 @@ public class CESEvent [JsonPropertyName("contract_package_hash")] public string ContractPackageHash { get; init; } + /// + /// The key in the global state that stores the event (dictionary item) + /// + public GlobalStateKey TransformKey { get; init; } + /// /// Zero-based index of the inside the execution-result /// effect list from which this event was extracted. @@ -63,10 +68,10 @@ public CESEvent(string name, IReadOnlyList fields) } /// - /// Returns the field with the given name, or null if not found. + /// Returns the CLValue of the field with the given name, or null if not found. /// - public NamedArg this[string fieldName] => - Fields.FirstOrDefault(f => f.Name == fieldName); + public CLValue this[string fieldName] => + Fields.FirstOrDefault(f => f.Name == fieldName)?.Value; // ───────────────────────────────────────────────────────────────────── // Parse @@ -95,8 +100,7 @@ public CESEvent(string name, IReadOnlyList fields) /// Thrown when the event name found in is not present in /// . /// - public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema, - int transformId = 0, string eventId = null) + public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema) { // Some CES implementations wrap the event payload in a Casper Vec (Bytes), // which prepends a u32 LE length equal to the remaining byte count. Detect and @@ -139,8 +143,6 @@ public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema, { ContractHash = schema.ContractHash, ContractPackageHash = schema.ContractPackageHash, - TransformId = transformId, - EventId = eventId, }; } diff --git a/Casper.Network.SDK/CES/CESParser.cs b/Casper.Network.SDK/CES/CESParser.cs index c426158..8698e10 100644 --- a/Casper.Network.SDK/CES/CESParser.cs +++ b/Casper.Network.SDK/CES/CESParser.cs @@ -81,7 +81,15 @@ public static List GetEvents( // 5. Parse the event payload, stamping it with execution-result context. try { - results.Add(CESEvent.ParseEvent(dict.Value, schema, i, dict.ItemKey)); + var evt = CESEvent.ParseEvent(dict.Value, schema); + results.Add(new CESEvent(evt.Name, evt.Fields) + { + ContractHash = schema.ContractHash, + ContractPackageHash = schema.ContractPackageHash, + TransformKey = transform.Key, + TransformId = i, + EventId = dict.ItemKey, + }); } catch (KeyNotFoundException) { diff --git a/Docs/Articles/CasperEventStandard.md b/Docs/Articles/CasperEventStandard.md index 7883148..f559521 100644 --- a/Docs/Articles/CasperEventStandard.md +++ b/Docs/Articles/CasperEventStandard.md @@ -2,7 +2,7 @@ The **Casper Event Standard (CES)** is a convention adopted by make-software for emitting and consuming typed events from Casper smart contracts. Contracts that follow CES store a self-describing schema alongside their events, making it possible to decode any event without out-of-band knowledge of its structure. -The Casper .NET SDK provides `CESParser` in the `Casper.Network.SDK.CES` namespace to handle both schema and event parsing. +The Casper .NET SDK provides the `Casper.Network.SDK.CES` namespace with three classes: `CESContractSchema` (schema loading and parsing), `CESEvent` (event parsing and field access), and `CESParser` (scanning execution results). --- @@ -78,19 +78,41 @@ Compound types (Option, List, Map, etc.) are encoded recursively — the tag is --- -## API Overview +## Loading a Contract Schema from the Network -### Parsing the Schema - -Retrieve the `__events_schema` named key from the contract's global state, extract the raw bytes from the CLValue, and pass them to `CESParser.ParseSchema`: +The simplest way to load a contract's schema is `CESContractSchema.LoadAsync`. It takes a `contractPackageHash`, resolves the latest active contract version, reads the named keys to locate `__events_schema` and `__events`, and fetches and parses the schema — all in a single call: ```csharp using Casper.Network.SDK.CES; +var client = new NetCasperClient("https://rpc.testnet.casperlabs.io/rpc"); + +CESContractSchema schema = await CESContractSchema.LoadAsync( + client, + "hash-17cb23ce7b6d663fc82bacbdd56a26dc722cabe7e69c84a3ca729bf3cb7fdc70"); +``` + +The returned `CESContractSchema` is fully annotated: + +| Property | Description | +|---|---| +| `Events` | Dictionary of event name → `CESEventSchema` | +| `ContractHash` | Hash of the active contract version | +| `ContractPackageHash` | Package hash passed in | +| `SchemaURef` | URef of the `__events_schema` named key | +| `EventsURef` | URef of the `__events` named key (used when scanning transforms) | + +Both legacy (Casper 1.x) and Casper 2.x contract models are supported automatically. + +### Parsing a Schema from Raw Bytes + +If you have already fetched the `__events_schema` CLValue from the network, you can parse it directly: + +```csharp byte[] schemaBytes = /* raw bytes from __events_schema CLValue */; -CESContractSchema schema = CESParser.ParseSchema(schemaBytes); +CESContractSchema schema = CESContractSchema.ParseSchema(schemaBytes); -// Check what events the contract supports +// Inspect the event types the contract supports foreach (var kvp in schema.Events) { Console.WriteLine($"Event: {kvp.Key}"); @@ -99,25 +121,29 @@ foreach (var kvp in schema.Events) } ``` -### Parsing an Event +Note that a schema created via `ParseSchema` directly has `SchemaURef` and `EventsURef` set to `null`. Use `LoadAsync` when you need these URefs. + +--- -Retrieve the value bytes for one entry from `__events` and pass them together with the schema to `CESParser.ParseEvent`: +## Parsing a Single Event + +To decode one event payload, pass the raw event bytes together with a schema to `CESEvent.ParseEvent`: ```csharp byte[] eventBytes = /* raw bytes of one entry from __events map */; -CESEvent evt = CESParser.ParseEvent(eventBytes, schema); +CESEvent evt = CESEvent.ParseEvent(eventBytes, schema); Console.WriteLine(evt.Name); // e.g. "event_Mint" -// Access fields by name -NamedArg amount = evt["amount"]; -Console.WriteLine(amount.Value.ToBigInteger()); // 1000000000000000000 +// Access field values directly by name — returns CLValue, or null if not found +CLValue recipient = evt["recipient"]; +Console.WriteLine(recipient.ToGlobalStateKey()); // hash-1262d06e... -NamedArg recipient = evt["recipient"]; -Console.WriteLine(recipient.Value.ToString()); // hash-1262d0... +CLValue amount = evt["amount"]; +Console.WriteLine(amount.ToBigInteger()); // 1000000000000000000 ``` -Each field in `CESEvent.Fields` is a `NamedArg`, exposing the field name and a fully-typed `CLValue` that supports the standard `ToXxx()` conversion methods (`ToBigInteger()`, `ToString()`, `ToBoolean()`, etc.). +The indexer `evt["fieldName"]` returns the `CLValue` of that field, or `null` if the field name is not found. The full list of fields is also available as `IReadOnlyList` through `evt.Fields`, which preserves both name and value. ### Looking Up an Event Schema @@ -126,17 +152,52 @@ if (schema.TryGetEventSchema("Mint", out CESEventSchema mintSchema)) { Console.WriteLine($"Mint has {mintSchema.Fields.Count} fields."); } +``` + +--- + +## Scanning Execution Results with `GetEvents` + +`CESParser.GetEvents` scans the transform list of an execution result and returns all CES events emitted by any of the watched contracts: -// Throws KeyNotFoundException if not found: -CESEventSchema transferSchema = schema.GetEventSchema("Transfer"); +```csharp +var events = CESParser.GetEvents( + executionResult.Effect, + new List { schema1, schema2 }); ``` +For each transform the method: +1. Skips transforms whose key is not a `DictionaryKey`. +2. Skips transforms whose kind is not a `WriteTransformKind`. +3. Tries to parse the CLValue as a `CLValueDictionary`; skips on failure. +4. Matches the dictionary seed against each schema's `EventsURef` (access rights are ignored; only the 32-byte hash is compared). +5. Parses the matching event payload using the schema. +6. Silently skips events whose name is not present in the schema (graceful version mismatch handling). + +Each schema in the list must have `EventsURef` set (schemas loaded via `LoadAsync` always have it). Schemas with a `null` `EventsURef` are silently skipped. + +The returned `CESEvent` objects include execution-result context: + +| Property | Description | +|---|---| +| `Name` | Raw event name as stored in bytes (e.g. `"event_Transfer"`) | +| `Fields` | Ordered list of `NamedArg` field values | +| `ContractHash` | Propagated from the matched schema | +| `ContractPackageHash` | Propagated from the matched schema | +| `TransformKey` | `GlobalStateKey` of the transform that emitted the event | +| `TransformId` | Zero-based index in the effect list | +| `EventId` | Sequential event counter key from `__events` | + --- ## Data Model ``` CESContractSchema + ├── ContractHash: string + ├── ContractPackageHash: string + ├── SchemaURef: URef + ├── EventsURef: URef └── Events: Dictionary └── CESEventSchema ├── EventName: string @@ -146,43 +207,67 @@ CESContractSchema CESEvent ├── Name: string (raw name, e.g. "event_Transfer") - └── Fields: IReadOnlyList - ├── Name: string - └── Value: CLValue + ├── Fields: IReadOnlyList + ├── ContractHash: string + ├── ContractPackageHash: string + ├── TransformKey: GlobalStateKey + ├── TransformId: int + └── EventId: string ``` --- -## Real-World Example — CEP-18 Token Contract +## Real-World Example — Watching Multiple Contracts -A CEP-18 fungible token contract exposes seven events: `Burn`, `DecreaseAllowance`, `IncreaseAllowance`, `Mint`, `SetAllowance`, `Transfer`, and `TransferFrom`. +The following example loads schemas for two contracts, fetches a transaction, and prints the events emitted: -A `Mint` event byte payload (hex) looks like this: +```csharp +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Casper.Network.SDK; +using Casper.Network.SDK.CES; -``` -38000000 ← Vec length = 56 bytes -0a000000 ← String length = 10 -6576656e745f4d696e74 ← "event_Mint" -01 ← recipient: Key tag (Account/Hash) -1262d06e...349 ← 32-byte key hash -08000000 ← amount: U512, 8 bytes -64a7b3b6e00d0000 ← 1 000 000 000 000 000 000 -``` +var client = new NetCasperClient("https://rpc.testnet.casperlabs.io/rpc"); -After parsing: +// Load the schema for each contract to watch +var minterSchema = await CESContractSchema.LoadAsync( + client, + "hash-1262d06e53125ea098187fb4d1d5b10a7afed48e5e5eef182ed992fc5b100349"); -```csharp -Console.WriteLine(evt.Name); // event_Mint -Console.WriteLine(evt["recipient"]); // hash-1262d06e...349 -Console.WriteLine(evt["amount"] - .Value.ToBigInteger()); // 1000000000000000000 +var cep18Schema = await CESContractSchema.LoadAsync( + client, + "hash-17cb23ce7b6d663fc82bacbdd56a26dc722cabe7e69c84a3ca729bf3cb7fdc70"); + +// Fetch a transaction and extract its execution result transforms +var getTxResult = await client.GetTransaction( + "8e46a16fc8fc15c38405e092959fb20acc44dcfca1d1caecb9bc59d018f50df6"); +var txResult = getTxResult.Parse(); + +// Scan for CES events emitted by either watched contract +var events = CESParser.GetEvents( + txResult.ExecutionInfo.ExecutionResult.Effect, + new List { minterSchema, cep18Schema }); + +// Work with a specific event +var buyEvent = events.FirstOrDefault(e => e.Name == "Buy"); +if (buyEvent != null) +{ + Console.WriteLine("Buy token: " + buyEvent["token"].ToGlobalStateKey()); + Console.WriteLine("Buy amount: " + buyEvent["amount_token_out"].ToBigInteger()); +} + +// Serialize all events to JSON +var json = JsonSerializer.Serialize(events); +Console.WriteLine(json); ``` --- ## Notes -- **Schema key** (`__events_schema`) and **events key** (`__events`) are both stored as named keys directly on the contract's `StoredContractByHash` or `StoredContractByName` entity. -- The `Vec` outer length prefix is auto-detected and skipped transparently by `ParseEvent`. - `CESEvent.Name` always reflects the exact string found in the event bytes, including the `"event_"` prefix. Schema lookup normalises the name by stripping that prefix automatically. -- This implementation targets the Casper 1.x named-key storage pattern. Casper 2.x may use a different mechanism. +- The `Vec` outer length prefix is auto-detected and skipped transparently by `ParseEvent`. +- `EventsURef` access rights are ignored when matching the dictionary seed — only the 32-byte hash is compared. +- Both legacy (`ContractPackage`/`Contract`) and Casper 2.x (`Package`/`AddressableEntity`) contract models are supported throughout `LoadAsync`. +- Schemas loaded via `ParseSchema` directly (not through `LoadAsync`) have `SchemaURef` and `EventsURef` set to `null` and cannot be used with `GetEvents`. diff --git a/Docs/Articles/toc.yml b/Docs/Articles/toc.yml index 9f25e4b..e0ec0a6 100644 --- a/Docs/Articles/toc.yml +++ b/Docs/Articles/toc.yml @@ -9,4 +9,6 @@ href: KeyManagement.md - name: Working with CLValue href: WorkingWithCLValue.md + - name: Casper Event Standard (CES) + href: CasperEventStandard.md From 83dce8259898e3a3c7ce3533738d5b3ed3c13256 Mon Sep 17 00:00:00 2001 From: David Hernando Date: Wed, 4 Mar 2026 14:56:33 +0100 Subject: [PATCH 4/5] Fix Unit tests. Added possibility to load schema from a contract hash in addition to from a package hash. Signed-off-by: David Hernando --- .../CESJsonSerializerTest.cs | 19 ++- .../CESParserGetEventsTest.cs | 35 ++--- Casper.Network.SDK.Test/CESParserTest.cs | 10 +- Casper.Network.SDK/CES/CESEvent.cs | 32 ++-- Casper.Network.SDK/CES/CESSchema.cs | 140 +++++++++++------- Docs/Articles/CasperEventStandard.md | 22 ++- 6 files changed, 144 insertions(+), 114 deletions(-) diff --git a/Casper.Network.SDK.Test/CESJsonSerializerTest.cs b/Casper.Network.SDK.Test/CESJsonSerializerTest.cs index 2886f60..7268ea3 100644 --- a/Casper.Network.SDK.Test/CESJsonSerializerTest.cs +++ b/Casper.Network.SDK.Test/CESJsonSerializerTest.cs @@ -49,7 +49,14 @@ private static CESContractSchema BuildAnnotatedSchema() => private static CESEvent BuildAnnotatedEvent() { var schema = BuildAnnotatedSchema(); - return CESEvent.ParseEvent(Hex.Decode(EventHex), schema, transformId: 3, eventId: "42"); + var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); + return new CESEvent(evt.Name, evt.Fields) + { + ContractHash = ContractHash, + ContractPackageHash = ContractPkgHash, + TransformId = 3, + EventId = "42", + }; } // ── CESEventSchemaField ─────────────────────────────────────────────── @@ -182,7 +189,7 @@ public void CESEvent_RoundTrip_PreservesName() var json = JsonSerializer.Serialize(evt); var restored = JsonSerializer.Deserialize(json); - Assert.AreEqual("event_Transfer", restored.Name); + Assert.AreEqual("Transfer", restored.Name); } [Test] @@ -206,8 +213,8 @@ public void CESEvent_RoundTrip_PreservesU512Field() var amount = restored["amount"]; Assert.IsNotNull(amount); - Assert.AreEqual(CLType.U512, amount.Value.TypeInfo.Type); - Assert.AreEqual(new BigInteger(100), amount.Value.ToBigInteger()); + Assert.AreEqual(CLType.U512, amount.TypeInfo.Type); + Assert.AreEqual(new BigInteger(100), amount.ToBigInteger()); } [Test] @@ -220,8 +227,8 @@ public void CESEvent_RoundTrip_PreservesStringField() var sender = restored["sender"]; Assert.IsNotNull(sender); - Assert.AreEqual(CLType.String, sender.Value.TypeInfo.Type); - Assert.AreEqual("Alice", sender.Value.ToString()); + Assert.AreEqual(CLType.String, sender.TypeInfo.Type); + Assert.AreEqual("Alice", sender.ToString()); } [Test] diff --git a/Casper.Network.SDK.Test/CESParserGetEventsTest.cs b/Casper.Network.SDK.Test/CESParserGetEventsTest.cs index c4151f3..1d62e00 100644 --- a/Casper.Network.SDK.Test/CESParserGetEventsTest.cs +++ b/Casper.Network.SDK.Test/CESParserGetEventsTest.cs @@ -344,7 +344,7 @@ public void GetEvents_SingleMatchingTransform_CorrectEventName() var result = CESParser.GetEvents(new List { transform }, watched); - Assert.AreEqual("event_Transfer", result[0].Name); + Assert.AreEqual("Transfer", result[0].Name); } [Test] @@ -355,7 +355,7 @@ public void GetEvents_SingleMatchingTransform_CorrectU512Field() var result = CESParser.GetEvents(new List { transform }, watched); - Assert.AreEqual(new BigInteger(100), result[0]["amount"].Value.ToBigInteger()); + Assert.AreEqual(new BigInteger(100), result[0]["amount"].ToBigInteger()); } [Test] @@ -366,7 +366,7 @@ public void GetEvents_SingleMatchingTransform_CorrectStringField() var result = CESParser.GetEvents(new List { transform }, watched); - Assert.AreEqual("Alice", result[0]["sender"].Value.ToString()); + Assert.AreEqual("Alice", result[0]["sender"].ToString()); } // ─── graceful skipping ───────────────────────────────────────────────── @@ -448,13 +448,13 @@ public void GetEvents_TwoEventsFromDifferentContracts_CorrectFieldValues() var result = CESParser.GetEvents(new List { t1, t2 }, watched); // first event: Transfer – check both fields - Assert.AreEqual("event_Transfer", result[0].Name); - Assert.AreEqual(new BigInteger(100), result[0]["amount"].Value.ToBigInteger()); - Assert.AreEqual("Alice", result[0]["sender"].Value.ToString()); + Assert.AreEqual("Transfer", result[0].Name); + Assert.AreEqual(new BigInteger(100), result[0]["amount"].ToBigInteger()); + Assert.AreEqual("Alice", result[0]["sender"].ToString()); // second event: Mint – check recipient - Assert.AreEqual("event_Mint", result[1].Name); - Assert.AreEqual(new BigInteger(200), result[1]["recipient"].Value.ToBigInteger()); + Assert.AreEqual("Mint", result[1].Name); + Assert.AreEqual(new BigInteger(200), result[1]["recipient"].ToBigInteger()); } // ─── ordering and mixed transforms ──────────────────────────────────── @@ -471,8 +471,8 @@ public void GetEvents_OrderPreserved_MatchesTransformOrder() var result = CESParser.GetEvents(new List { t1, t2 }, watched); Assert.AreEqual(2, result.Count); - Assert.AreEqual("Alice", result[0]["sender"].Value.ToString()); - Assert.AreEqual("Bob", result[1]["sender"].Value.ToString()); + Assert.AreEqual("Alice", result[0]["sender"].ToString()); + Assert.AreEqual("Bob", result[1]["sender"].ToString()); } [Test] @@ -501,7 +501,7 @@ public void GetEvents_MixedTransforms_OnlyMatchingEventsReturned() var result = CESParser.GetEvents(new List { t1, t2, t3, t4 }, watched); Assert.AreEqual(1, result.Count); - Assert.AreEqual("event_Transfer", result[0].Name); + Assert.AreEqual("Transfer", result[0].Name); } [Test] @@ -526,7 +526,7 @@ public void GetEvents_OneValidOneBadParseable_OnlyValidEventReturned() var result = CESParser.GetEvents(new List { t1, t2 }, watched); Assert.AreEqual(1, result.Count); - Assert.AreEqual("event_Transfer", result[0].Name); + Assert.AreEqual("Transfer", result[0].Name); } // ─── TransformId and EventId ─────────────────────────────────────────── @@ -667,17 +667,8 @@ public void ParseEvent_InIsolation_TransformIdIsZeroAndEventIdIsNull() var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); Assert.AreEqual(0, evt.TransformId); + Assert.IsNull(evt.TransformKey); Assert.IsNull(evt.EventId); } - - [Test] - public void ParseEvent_WithExplicitContext_TransformIdAndEventIdAreSet() - { - var schema = ParseSchema(SchemaHex); - var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema, transformId: 5, eventId: "99"); - - Assert.AreEqual(5, evt.TransformId); - Assert.AreEqual("99", evt.EventId); - } } } diff --git a/Casper.Network.SDK.Test/CESParserTest.cs b/Casper.Network.SDK.Test/CESParserTest.cs index 5a34814..a2605a7 100644 --- a/Casper.Network.SDK.Test/CESParserTest.cs +++ b/Casper.Network.SDK.Test/CESParserTest.cs @@ -86,7 +86,7 @@ public void ParseEvent_CorrectEventName() var schema = CESContractSchema.ParseSchema(Hex.Decode(SchemaHex)); var evt = CESEvent.ParseEvent(Hex.Decode(EventHex), schema); - Assert.AreEqual("event_Transfer", evt.Name); + Assert.AreEqual("Transfer", evt.Name); } [Test] @@ -225,9 +225,11 @@ public void ParseSchema_TupleType_CorrectInnerTypes() "0a0000006576656e745f4d696e74011262d06e53125ea098187fb4d1d5b10a7afed48e5e5eef182ed992fc5b10034908000064a7b3b6e00d"; var parsedEvt = CESEvent.ParseEvent(Hex.Decode(evt0), schema); Assert.IsNotNull(parsedEvt); - Assert.AreEqual("event_Mint", parsedEvt.Name); - // var amount = parsedEvt["amount"]; - // amount.Value + Assert.AreEqual("Mint", parsedEvt.Name); + var amount = parsedEvt["amount"]; + Assert.IsNotNull(amount); + Assert.IsNotNull(amount.ToBigInteger()); + Assert.AreEqual("1000000000000000000", amount.ToBigInteger().ToString() ); } } } diff --git a/Casper.Network.SDK/CES/CESEvent.cs b/Casper.Network.SDK/CES/CESEvent.cs index f3bd205..5f359fa 100644 --- a/Casper.Network.SDK/CES/CESEvent.cs +++ b/Casper.Network.SDK/CES/CESEvent.cs @@ -10,8 +10,8 @@ namespace Casper.Network.SDK.CES { /// - /// A fully parsed CES event containing the event name, its typed fields, and the - /// execution-context metadata set by . + /// A fully parsed CES event containing the event name, its typed fields, and, optionally, the + /// execution-context metadata of the event. /// public class CESEvent { @@ -23,23 +23,22 @@ public class CESEvent public IReadOnlyList Fields { get; } /// - /// The contract hash of the emitting contract (e.g. "hash-abc…def"), - /// propagated from the supplied to the parser. - /// null when the schema was not annotated with a contract hash. + /// The contract hash of the emitting contract (e.g. "hash-abc…def"). + /// Set by ; null when parsed in isolation. /// [JsonPropertyName("contract_hash")] public string ContractHash { get; init; } /// - /// The contract-package hash of the emitting contract, - /// propagated from the supplied to the parser. - /// null when the schema was not annotated with a contract-package hash. + /// The contract-package hash of the emitting contract. + /// Set by ; null when parsed in isolation. /// [JsonPropertyName("contract_package_hash")] public string ContractPackageHash { get; init; } /// /// The key in the global state that stores the event (dictionary item) + /// Set by ; null when parsed in isolation. /// public GlobalStateKey TransformKey { get; init; } @@ -86,25 +85,14 @@ public CESEvent(string name, IReadOnlyList fields) /// /// The contract schema obtained from . /// - /// - /// Zero-based index of the source in the execution-result - /// effect list. Used to populate . - /// Pass 0 (the default) when parsing in isolation. - /// - /// - /// The string key that identifies this entry in the contract's __events - /// dictionary. Used to populate . - /// Pass null (the default) when parsing in isolation. - /// /// /// Thrown when the event name found in is not present in /// . /// public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema) { - // Some CES implementations wrap the event payload in a Casper Vec (Bytes), - // which prepends a u32 LE length equal to the remaining byte count. Detect and - // skip that outer wrapper transparently. + // Depending on the origin of the rawBytes, the actual payload can be prepended with a u32 LE length value. + // Detect and skip that outer wrapper transparently. var offset = 0; if (rawBytes.Length >= 4) { @@ -120,7 +108,7 @@ public static CESEvent ParseEvent(byte[] rawBytes, CESContractSchema schema) var rawName = reader.ReadCLString(); - // Strip the "event_" prefix that some CES implementations prepend to event names. + // Strip the "event_" prefix that CES implementation prepend to event names. const string prefix = "event_"; var eventName = rawName.StartsWith(prefix) ? rawName.Substring(prefix.Length) diff --git a/Casper.Network.SDK/CES/CESSchema.cs b/Casper.Network.SDK/CES/CESSchema.cs index da7ef26..a09a7ff 100644 --- a/Casper.Network.SDK/CES/CESSchema.cs +++ b/Casper.Network.SDK/CES/CESSchema.cs @@ -168,13 +168,21 @@ public static CESContractSchema ParseSchema(byte[] rawBytes) /// /// /// - /// The method calls GetPackage to enumerate all active versions of the contract - /// package and then resolves the target contract: + /// The parameter accepts either a + /// contract hash or a contract-package hash: /// /// - /// When is provided, that exact version is selected. /// - /// When is null (the default), the highest-numbered + /// If the value starts with "contract-" (e.g. "contract-dead…beef") + /// it is treated as a direct contract hash and the package-resolution step is skipped. + /// The parameter is ignored in this case. + /// + /// + /// Otherwise it is treated as a contract-package hash + /// (e.g. "hash-abc…def" or "package-abc…def") + /// and the method enumerates the package to resolve the target contract version: + /// when is provided that exact version is selected; + /// when is null (the default) the highest-numbered /// active (non-disabled) version is used. /// /// @@ -193,16 +201,18 @@ public static CESContractSchema ParseSchema(byte[] rawBytes) /// /// An active instance. /// - /// The contract-package hash string, e.g. - /// "contract-package-hash-abc…def" or "package-abc…def". + /// Either a contract hash (prefix "contract-") or a contract-package hash + /// (e.g. "hash-abc…def" or "package-abc…def"). /// /// - /// The contract version number to load. Pass null (the default) to load - /// the latest active version. + /// The contract version number to load when a package hash is supplied. + /// Pass null (the default) to load the latest active version. + /// Ignored when a direct contract hash is provided. /// /// /// A fully-populated with /// and set. + /// When a direct contract hash is supplied, is null. /// /// /// Thrown when or is null. @@ -225,65 +235,76 @@ public static async Task LoadAsync( if (string.IsNullOrWhiteSpace(contractPackageHash)) throw new ArgumentNullException(nameof(contractPackageHash)); - // ── 1. Fetch the package to enumerate versions ──────────────────── - var pkgResult = (await client.QueryGlobalState(contractPackageHash)).Parse(); - - // ── 2. Resolve the target contract version ──────────────────────── string contractHash; + GlobalStateKey resolvedPackageHash; - if (pkgResult.StoredValue != null && - pkgResult.StoredValue.ContractPackage != null) + if (contractPackageHash.StartsWith("contract-", StringComparison.OrdinalIgnoreCase)) { - // Legacy node: Versions contains only active (non-disabled) entries. - var pkg = pkgResult.StoredValue.ContractPackage; - - ContractVersion chosen; - if (version.HasValue) - { - chosen = pkg.Versions.FirstOrDefault(v => v.Version == version.Value) - ?? throw new KeyNotFoundException( - $"Version {version.Value} not found in contract package '{contractPackageHash}'."); - } - else - { - chosen = pkg.Versions.OrderByDescending(v => v.Version).FirstOrDefault() - ?? throw new InvalidOperationException( - $"Contract package '{contractPackageHash}' has no active versions."); - } - - contractHash = chosen.Hash; // e.g. "contract-hash-abc…def" + // Input is already a contract hash — skip package resolution entirely. + contractHash = contractPackageHash; + resolvedPackageHash = null; } - else if (pkgResult.StoredValue != null && - pkgResult.StoredValue.Package != null) + else { - // New node: Versions contains all versions; filter out disabled ones for latest. - var pkg = pkgResult.StoredValue.Package; - var disabledSet = new HashSet( - pkg.DisabledVersions?.Select(d => d.Version) ?? Enumerable.Empty()); + // ── 1. Fetch the package to enumerate versions ──────────────────── + var pkgResult = (await client.QueryGlobalState(contractPackageHash)).Parse(); + resolvedPackageHash = GlobalStateKey.FromString(contractPackageHash); - EntityVersionAndHash chosen; - if (version.HasValue) + // ── 2. Resolve the target contract version ──────────────────────── + if (pkgResult.StoredValue != null && + pkgResult.StoredValue.ContractPackage != null) + { + // Legacy node: Versions contains only active (non-disabled) entries. + var pkg = pkgResult.StoredValue.ContractPackage; + + ContractVersion chosen; + if (version.HasValue) + { + chosen = pkg.Versions.FirstOrDefault(v => v.Version == version.Value) + ?? throw new KeyNotFoundException( + $"Version {version.Value} not found in contract package '{contractPackageHash}'."); + } + else + { + chosen = pkg.Versions.OrderByDescending(v => v.Version).FirstOrDefault() + ?? throw new InvalidOperationException( + $"Contract package '{contractPackageHash}' has no active versions."); + } + + contractHash = chosen.Hash; // e.g. "contract-hash-abc…def" + } + else if (pkgResult.StoredValue != null && + pkgResult.StoredValue.Package != null) { - chosen = pkg.Versions.FirstOrDefault(v => v.EntityVersion.Version == version.Value) - ?? throw new KeyNotFoundException( - $"Version {version.Value} not found in contract package '{contractPackageHash}'."); + // New node: Versions contains all versions; filter out disabled ones for latest. + var pkg = pkgResult.StoredValue.Package; + var disabledSet = new HashSet( + pkg.DisabledVersions?.Select(d => d.Version) ?? Enumerable.Empty()); + + EntityVersionAndHash chosen; + if (version.HasValue) + { + chosen = pkg.Versions.FirstOrDefault(v => v.EntityVersion.Version == version.Value) + ?? throw new KeyNotFoundException( + $"Version {version.Value} not found in contract package '{contractPackageHash}'."); + } + else + { + chosen = pkg.Versions + .Where(v => !disabledSet.Contains(v.EntityVersion.Version)) + .OrderByDescending(v => v.EntityVersion.Version) + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Contract package '{contractPackageHash}' has no active versions."); + } + + contractHash = chosen.AddressableEntity.ToString(); // e.g. "entity-contract-abc…def" } else { - chosen = pkg.Versions - .Where(v => !disabledSet.Contains(v.EntityVersion.Version)) - .OrderByDescending(v => v.EntityVersion.Version) - .FirstOrDefault() - ?? throw new InvalidOperationException( - $"Contract package '{contractPackageHash}' has no active versions."); + throw new InvalidOperationException( + $"GetPackage returned neither a ContractPackage nor a Package for '{contractPackageHash}'."); } - - contractHash = chosen.AddressableEntity.ToString(); // e.g. "entity-contract-abc…def" - } - else - { - throw new InvalidOperationException( - $"GetPackage returned neither a ContractPackage nor a Package for '{contractPackageHash}'."); } var contractKey = GlobalStateKey.FromString(contractHash); @@ -297,6 +318,8 @@ public static async Task LoadAsync( namedKeys = entityResult.NamedKeys ?? throw new InvalidOperationException( $"No named keys returned for entity '{contractHash}'."); + + resolvedPackageHash ??= entityResult.Entity.Package; } else { @@ -306,6 +329,9 @@ public static async Task LoadAsync( namedKeys = contractResult.StoredValue?.Contract?.NamedKeys ?? throw new InvalidOperationException( $"No named keys found for contract '{contractHash}'."); + + resolvedPackageHash ??= GlobalStateKey.FromString( + contractResult.StoredValue.Contract.ContractPackageHash); } var schemaNamedKey = namedKeys.FirstOrDefault(k => k.Name == "__events_schema") @@ -336,7 +362,7 @@ public static async Task LoadAsync( return new CESContractSchema(parsed.Events) { ContractHash = contractKey.ToString(), - ContractPackageHash = contractPackageHash, + ContractPackageHash = resolvedPackageHash.ToString(), SchemaURef = schemaURef, EventsURef = eventsURef, }; diff --git a/Docs/Articles/CasperEventStandard.md b/Docs/Articles/CasperEventStandard.md index f559521..3faac5a 100644 --- a/Docs/Articles/CasperEventStandard.md +++ b/Docs/Articles/CasperEventStandard.md @@ -80,25 +80,40 @@ Compound types (Option, List, Map, etc.) are encoded recursively — the tag is ## Loading a Contract Schema from the Network -The simplest way to load a contract's schema is `CESContractSchema.LoadAsync`. It takes a `contractPackageHash`, resolves the latest active contract version, reads the named keys to locate `__events_schema` and `__events`, and fetches and parses the schema — all in a single call: +The simplest way to load a contract's schema is `CESContractSchema.LoadAsync`. It accepts either a **contract-package hash** or a direct **contract hash**, reads the named keys to locate `__events_schema` and `__events`, and fetches and parses the schema — all in a single call. + +### Using a contract-package hash + +When you pass a contract-package hash, the method resolves the latest active contract version automatically. You can also request a specific version with the optional `version` parameter. ```csharp using Casper.Network.SDK.CES; var client = new NetCasperClient("https://rpc.testnet.casperlabs.io/rpc"); +// Load the schema for the latest active version of the contract package CESContractSchema schema = await CESContractSchema.LoadAsync( client, "hash-17cb23ce7b6d663fc82bacbdd56a26dc722cabe7e69c84a3ca729bf3cb7fdc70"); ``` +### Using a contract hash directly + +If you already know the contract hash, you can pass it directly and the package-resolution step is skipped entirely. Use `"contract-"` prefix instead of `"hash-"` for a contract hash argument.The `version` parameter is ignored in this case. + +```csharp +CESContractSchema schema = await CESContractSchema.LoadAsync( + client, + "contract-dead1234dead1234dead1234dead1234dead1234dead1234dead1234dead1234beef"); +``` + The returned `CESContractSchema` is fully annotated: | Property | Description | |---|---| | `Events` | Dictionary of event name → `CESEventSchema` | -| `ContractHash` | Hash of the active contract version | -| `ContractPackageHash` | Package hash passed in | +| `ContractHash` | Hash of the resolved (or supplied) contract version | +| `ContractPackageHash` | Package hash passed in, or `null` when a direct contract hash was supplied | | `SchemaURef` | URef of the `__events_schema` named key | | `EventsURef` | URef of the `__events` named key (used when scanning transforms) | @@ -270,4 +285,5 @@ Console.WriteLine(json); - The `Vec` outer length prefix is auto-detected and skipped transparently by `ParseEvent`. - `EventsURef` access rights are ignored when matching the dictionary seed — only the 32-byte hash is compared. - Both legacy (`ContractPackage`/`Contract`) and Casper 2.x (`Package`/`AddressableEntity`) contract models are supported throughout `LoadAsync`. +- `LoadAsync` accepts either a contract-package hash or a direct contract hash. If the value starts with `"contract-"` it is used as-is and package resolution is skipped; `ContractPackageHash` will be `null` in the resulting schema. - Schemas loaded via `ParseSchema` directly (not through `LoadAsync`) have `SchemaURef` and `EventsURef` set to `null` and cannot be used with `GetEvents`. From a16d72f71d2b09f55dcda1a65429327b5d8f0a8a Mon Sep 17 00:00:00 2001 From: David Hernando Date: Wed, 4 Mar 2026 15:15:20 +0100 Subject: [PATCH 5/5] corrections to documentation. Signed-off-by: David Hernando --- Docs/Articles/CasperEventStandard.md | 71 ++++++---------------------- 1 file changed, 14 insertions(+), 57 deletions(-) diff --git a/Docs/Articles/CasperEventStandard.md b/Docs/Articles/CasperEventStandard.md index 3faac5a..6d87478 100644 --- a/Docs/Articles/CasperEventStandard.md +++ b/Docs/Articles/CasperEventStandard.md @@ -1,6 +1,6 @@ # Casper Event Standard (CES) -The **Casper Event Standard (CES)** is a convention adopted by make-software for emitting and consuming typed events from Casper smart contracts. Contracts that follow CES store a self-describing schema alongside their events, making it possible to decode any event without out-of-band knowledge of its structure. +The **Casper Event Standard (CES)** is a convention adopted by contract developers for emitting and consuming typed events from Casper smart contracts. Contracts that follow CES store a self-describing schema alongside their events, making it possible to decode any event without out-of-band knowledge of its structure. The Casper .NET SDK provides the `Casper.Network.SDK.CES` namespace with three classes: `CESContractSchema` (schema loading and parsing), `CESEvent` (event parsing and field access), and `CESParser` (scanning execution results). @@ -8,12 +8,12 @@ The Casper .NET SDK provides the `Casper.Network.SDK.CES` namespace with three c ## How CES Works -A CES-compliant contract uses two special named keys: +A CES-compliant contract uses two special named keys for events: -| Named key | CLType | Purpose | +| Named key | Purpose | |---|---|---| -| `__events_schema` | `Any` | Binary-encoded schema listing every event type and its fields | -| `__events` | `Map(U32, Bytes)` | Ordered map of event index → raw event bytes | +| `__events_schema` | Binary-encoded schema listing every event type and its fields | +| `__events` | Ordered map of event index → raw event bytes | ### Schema (`__events_schema`) @@ -29,7 +29,7 @@ for each event: CLType bytes — Casper binary type encoding (tag byte + optional inner types) ``` -All integers are little-endian. The `String` encoding used here is the standard Casper string encoding: a `u32` length followed by the UTF-8 content (no null terminator). +The `String` encoding used here is the standard Casper string encoding: a `u32` length followed by the UTF-8 content (no null terminator). ### Events (`__events`) @@ -42,39 +42,7 @@ for each field (in schema order): raw field bytes — native Casper serialization, no CLValue wrapper ``` -The `"event_"` prefix is stripped internally when looking up the event in the schema, but `CESEvent.Name` preserves the original name exactly as stored in the bytes. - -### CLType Binary Encoding - -CLType tags used in the schema are single bytes: - -| CLType | Tag | -|---|---| -| `Bool` | `0x00` | -| `I32` | `0x01` | -| `I64` | `0x02` | -| `U8` | `0x03` | -| `U32` | `0x04` | -| `U64` | `0x05` | -| `U128` | `0x06` | -| `U256` | `0x07` | -| `U512` | `0x08` | -| `Unit` | `0x09` | -| `String` | `0x0a` | -| `Key` | `0x0b` | -| `URef` | `0x0c` | -| `Option` | `0x0d` + inner type | -| `List` | `0x0e` + item type | -| `ByteArray` | `0x0f` + `u32` size | -| `Result` | `0x10` + ok type + err type | -| `Map` | `0x11` + key type + value type | -| `Tuple1` | `0x12` + type₁ | -| `Tuple2` | `0x13` + type₁ + type₂ | -| `Tuple3` | `0x14` + type₁ + type₂ + type₃ | -| `Any` | `0x15` | -| `PublicKey` | `0x16` | - -Compound types (Option, List, Map, etc.) are encoded recursively — the tag is followed immediately by its inner type tags. +The `"event_"` prefix is stripped automatically when parsing the event raw bytes. --- @@ -109,12 +77,12 @@ CESContractSchema schema = await CESContractSchema.LoadAsync( The returned `CESContractSchema` is fully annotated: -| Property | Description | -|---|---| -| `Events` | Dictionary of event name → `CESEventSchema` | -| `ContractHash` | Hash of the resolved (or supplied) contract version | -| `ContractPackageHash` | Package hash passed in, or `null` when a direct contract hash was supplied | -| `SchemaURef` | URef of the `__events_schema` named key | +| Property | Description | +|---|------------------------------------------------------------------| +| `Events` | Dictionary of event name → `CESEventSchema` | +| `ContractHash` | Hash of the resolved (or supplied) contract version | +| `ContractPackageHash` | Package hash passed in. May be `null` when a direct contract hash was supplied. | +| `SchemaURef` | URef of the `__events_schema` named key | | `EventsURef` | URef of the `__events` named key (used when scanning transforms) | Both legacy (Casper 1.x) and Casper 2.x contract models are supported automatically. @@ -243,7 +211,7 @@ using System.Text.Json; using Casper.Network.SDK; using Casper.Network.SDK.CES; -var client = new NetCasperClient("https://rpc.testnet.casperlabs.io/rpc"); +var client = new NetCasperClient("https://node.testnet.casper.network/rpc"); // Load the schema for each contract to watch var minterSchema = await CESContractSchema.LoadAsync( @@ -276,14 +244,3 @@ if (buyEvent != null) var json = JsonSerializer.Serialize(events); Console.WriteLine(json); ``` - ---- - -## Notes - -- `CESEvent.Name` always reflects the exact string found in the event bytes, including the `"event_"` prefix. Schema lookup normalises the name by stripping that prefix automatically. -- The `Vec` outer length prefix is auto-detected and skipped transparently by `ParseEvent`. -- `EventsURef` access rights are ignored when matching the dictionary seed — only the 32-byte hash is compared. -- Both legacy (`ContractPackage`/`Contract`) and Casper 2.x (`Package`/`AddressableEntity`) contract models are supported throughout `LoadAsync`. -- `LoadAsync` accepts either a contract-package hash or a direct contract hash. If the value starts with `"contract-"` it is used as-is and package resolution is skipped; `ContractPackageHash` will be `null` in the resulting schema. -- Schemas loaded via `ParseSchema` directly (not through `LoadAsync`) have `SchemaURef` and `EventsURef` set to `null` and cannot be used with `GetEvents`.