diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php new file mode 100644 index 0000000..a3418ea --- /dev/null +++ b/src/Metadata/Metadata.php @@ -0,0 +1,232 @@ + Type definitions indexed by ID + */ + private array $types = []; + + /** + * @var array Pallets indexed by name + */ + private array $pallets = []; + + /** + * @var array Pallets indexed by index + */ + private array $palletsByIndex = []; + + /** + * @var array|null Cached type map for quick lookup + */ + private ?array $typeCache = null; + + /** + * @param MetadataVersion $version Metadata version + * @param array $apis Runtime APIs (v15+) + * @param array $extrinsic Extrinsic metadata + * @param array $outerEvent Outer event types + */ + public function __construct( + public readonly MetadataVersion $version, + public readonly array $apis = [], + public readonly array $extrinsic = [], + public readonly array $outerEvent = [], + ) {} + + /** + * Add a type definition. + */ + public function addType(TypeDefinition $type): void + { + $this->types[$type->id] = $type; + $this->typeCache = null; // Invalidate cache + } + + /** + * Get a type definition by ID. + */ + public function getType(int $id): ?TypeDefinition + { + return $this->types[$id] ?? null; + } + + /** + * Get all type definitions. + * + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * Add a pallet. + */ + public function addPallet(Pallet $pallet): void + { + $this->pallets[$pallet->name] = $pallet; + $this->palletsByIndex[$pallet->index] = $pallet; + } + + /** + * Get a pallet by name. + */ + public function getPallet(string $name): ?Pallet + { + return $this->pallets[$name] ?? null; + } + + /** + * Get a pallet by index. + */ + public function getPalletByIndex(int $index): ?Pallet + { + return $this->palletsByIndex[$index] ?? null; + } + + /** + * Get all pallets. + * + * @return array + */ + public function getPallets(): array + { + return $this->pallets; + } + + /** + * Get type ID by name/path. + */ + public function getTypeIdByName(string $name): ?int + { + // Build cache if needed + if ($this->typeCache === null) { + $this->typeCache = []; + foreach ($this->types as $type) { + if (!empty($type->path)) { + // path is already a string like "frame_system::AccountInfo" + $this->typeCache[$type->path] = $type->id; + // Also index by last segment + $parts = explode('::', $type->path); + $lastName = end($parts); + if ($lastName) { + $this->typeCache[$lastName] = $type->id; + } + } + } + } + + return $this->typeCache[$name] ?? null; + } + + /** + * Get extrinsic version. + */ + public function getExtrinsicVersion(): int + { + return $this->extrinsic['version'] ?? 4; + } + + /** + * Get extrinsic address type. + */ + public function getExtrinsicAddressType(): ?int + { + return $this->extrinsic['addressType'] ?? null; + } + + /** + * Get extrinsic call type. + */ + public function getExtrinsicCallType(): ?int + { + return $this->extrinsic['callType'] ?? null; + } + + /** + * Get extrinsic signature type. + */ + public function getExtrinsicSignatureType(): ?int + { + return $this->extrinsic['signatureType'] ?? null; + } + + /** + * Get extrinsic extra type. + */ + public function getExtrinsicExtraType(): ?int + { + return $this->extrinsic['extraType'] ?? null; + } + + /** + * Register types from metadata into the registry. + */ + public function registerTypes(TypeRegistry $registry): void + { + $typeDefinitions = []; + + foreach ($this->types as $type) { + $name = !empty($type->path) ? implode('::', $type->path) : "Type{$type->id}"; + $typeDefinitions[$name] = $this->convertToRegistryFormat($type); + } + + $registry->registerFromMetadata($typeDefinitions); + } + + /** + * Convert TypeDefinition to registry format. + */ + private function convertToRegistryFormat(TypeDefinition $type): array + { + $result = ['id' => $type->id]; + + if ($type->isComposite()) { + $result['type'] = 'struct'; + $result['fields'] = []; + foreach ($type->getFields() as $field) { + $result['fields'][] = [ + 'name' => $field['name'] ?? '', + 'type' => $field['type'] ?? 0, + ]; + } + } elseif ($type->isVariant()) { + $result['type'] = 'enum'; + $result['variants'] = []; + foreach ($type->getVariants() as $variant) { + $result['variants'][] = [ + 'name' => $variant['name'] ?? '', + 'index' => $variant['index'] ?? 0, + 'fields' => $variant['fields'] ?? null, + ]; + } + } elseif ($type->isSequence()) { + $result['type'] = 'sequence'; + $result['elementType'] = $type->getElementType(); + } elseif ($type->isArray()) { + $arrInfo = $type->getArrayInfo(); + $result['type'] = 'array'; + $result['elementType'] = $arrInfo['type']; + $result['length'] = $arrInfo['len']; + } elseif ($type->isTuple()) { + $result['type'] = 'tuple'; + $result['types'] = $type->getTupleTypes(); + } + + return $result; + } +} diff --git a/src/Metadata/MetadataParser.php b/src/Metadata/MetadataParser.php new file mode 100644 index 0000000..efa3883 --- /dev/null +++ b/src/Metadata/MetadataParser.php @@ -0,0 +1,728 @@ +registry = $registry ?? new TypeRegistry(); + } + + /** + * Parse metadata from hex string. + * + * @param string $hex Hex string (with or without 0x prefix) + * @param bool $useCache Whether to use cached result + * @return Metadata Parsed metadata + * @throws ScaleDecodeException If parsing fails + */ + public function parse(string $hex, bool $useCache = true): Metadata + { + // Normalize hex + if (!str_starts_with($hex, '0x')) { + $hex = '0x' . $hex; + } + + // Check cache + $cacheKey = md5($hex); + if ($useCache && isset(self::$cache[$cacheKey])) { + return self::$cache[$cacheKey]; + } + + $bytes = ScaleBytes::fromHex($hex); + $metadata = $this->parseBytes($bytes); + + // Cache result + if ($useCache) { + self::$cache[$cacheKey] = $metadata; + } + + return $metadata; + } + + /** + * Parse metadata from bytes. + */ + private function parseBytes(ScaleBytes $bytes): Metadata + { + // Read magic number + $magic = $this->readU32($bytes); + if ($magic !== 0x6174656D) { // "meta" in little-endian + throw new ScaleDecodeException('Invalid metadata magic number'); + } + + // Read version + $version = $bytes->readByte(); + $metadataVersion = MetadataVersion::fromInt($version); + + if ($metadataVersion === null) { + throw new ScaleDecodeException("Unsupported metadata version: $version"); + } + + // Parse based on version + return match ($metadataVersion) { + MetadataVersion::V12, MetadataVersion::V13 => $this->parseV12V13($bytes, $metadataVersion), + MetadataVersion::V14 => $this->parseV14($bytes, $metadataVersion), + MetadataVersion::V15 => $this->parseV15($bytes, $metadataVersion), + }; + } + + /** + * Parse v12/v13 metadata. + */ + private function parseV12V13(ScaleBytes $bytes, MetadataVersion $version): Metadata + { + $metadata = new Metadata($version); + + // Parse modules + $moduleCount = $this->readCompact($bytes); + + for ($i = 0; $i < $moduleCount; $i++) { + $pallet = $this->parseModuleV12V13($bytes, $i); + $metadata->addPallet($pallet); + } + + return $metadata; + } + + /** + * Parse a module (pallet) for v12/v13. + */ + private function parseModuleV12V13(ScaleBytes $bytes, int $index): Pallet + { + $name = $this->readString($bytes); + + // Parse storage if present + $storage = []; + $hasStorage = $bytes->readByte(); + if ($hasStorage) { + $storagePrefix = $this->readString($bytes); + $storageCount = $this->readCompact($bytes); + for ($i = 0; $i < $storageCount; $i++) { + $storage[] = $this->parseStorageEntry($bytes); + } + } + + // Parse calls if present + $calls = []; + $hasCalls = $bytes->readByte(); + if ($hasCalls) { + $callCount = $this->readCompact($bytes); + for ($i = 0; $i < $callCount; $i++) { + $calls[] = $this->parseFunction($bytes); + } + } + + // Parse events if present + $events = []; + $hasEvents = $bytes->readByte(); + if ($hasEvents) { + $eventCount = $this->readCompact($bytes); + for ($i = 0; $i < $eventCount; $i++) { + $events[] = $this->parseFunction($bytes); + } + } + + // Parse constants + $constantCount = $this->readCompact($bytes); + $constants = []; + for ($i = 0; $i < $constantCount; $i++) { + $constants[] = $this->parseConstant($bytes); + } + + // Parse errors if present + $errors = []; + $hasErrors = $bytes->readByte(); + if ($hasErrors) { + $errorCount = $this->readCompact($bytes); + for ($i = 0; $i < $errorCount; $i++) { + $errors[] = $this->parseFunction($bytes); + } + } + + return new Pallet( + name: $name, + index: $index, + storage: $storage, + calls: $calls, + events: $events, + errors: $errors, + constants: $constants, + ); + } + + /** + * Parse v14 metadata. + */ + private function parseV14(ScaleBytes $bytes, MetadataVersion $version): Metadata + { + $metadata = new Metadata($version); + + // Parse types + $typeCount = $this->readCompact($bytes); + for ($i = 0; $i < $typeCount; $i++) { + $type = $this->parseType($bytes, $i); + $metadata->addType($type); + } + + // Parse pallets + $palletCount = $this->readCompact($bytes); + for ($i = 0; $i < $palletCount; $i++) { + $pallet = $this->parsePalletV14($bytes, $i, $metadata); + $metadata->addPallet($pallet); + } + + // Parse extrinsic + $extrinsic = $this->parseExtrinsic($bytes); + $metadata = new Metadata( + version: $version, + extrinsic: $extrinsic, + ); + + // Re-add types and pallets + foreach ($this->types ?? [] as $type) { + $metadata->addType($type); + } + + return $metadata; + } + + /** + * Parse v15 metadata. + */ + private function parseV15(ScaleBytes $bytes, MetadataVersion $version): Metadata + { + // Parse types first + $types = []; + $typeCount = $this->readCompact($bytes); + for ($i = 0; $i < $typeCount; $i++) { + $types[$i] = $this->parseType($bytes, $i); + } + + // Parse pallets + $pallets = []; + $palletCount = $this->readCompact($bytes); + for ($i = 0; $i < $palletCount; $i++) { + $pallets[$i] = $this->parsePalletV14($bytes, $i); + } + + // Parse extrinsic + $extrinsic = $this->parseExtrinsic($bytes); + + // Parse runtime APIs (new in v15) + $apiCount = $this->readCompact($bytes); + $apis = []; + for ($i = 0; $i < $apiCount; $i++) { + $apis[] = $this->parseRuntimeApi($bytes); + } + + // Parse outer event types (optional in v15) + $outerEvent = []; + if ($bytes->hasRemaining()) { + try { + $outerEvent = $this->parseOuterEvent($bytes); + } catch (\Exception) { + // Ignore if not present + } + } + + $metadata = new Metadata( + version: $version, + apis: $apis, + extrinsic: $extrinsic, + outerEvent: $outerEvent, + ); + + foreach ($types as $type) { + $metadata->addType($type); + } + + foreach ($pallets as $pallet) { + $metadata->addPallet($pallet); + } + + return $metadata; + } + + /** + * Parse a type definition. + */ + private function parseType(ScaleBytes $bytes, int $id): TypeDefinition + { + $path = $this->parsePath($bytes); + + // Parse type parameters + $paramCount = $this->readCompact($bytes); + $params = []; + for ($i = 0; $i < $paramCount; $i++) { + $params[] = $this->parseTypeParameter($bytes); + } + + // Parse type definition + $def = $this->parseTypeDef($bytes); + + // Parse docs + $docs = $this->parseDocs($bytes); + + return new TypeDefinition( + id: $id, + path: implode('::', $path), + params: $params, + def: $def, + docs: $docs, + ); + } + + /** + * Parse type path. + */ + private function parsePath(ScaleBytes $bytes): array + { + $count = $this->readCompact($bytes); + $path = []; + for ($i = 0; $i < $count; $i++) { + $path[] = $this->readString($bytes); + } + return $path; + } + + /** + * Parse type parameter. + */ + private function parseTypeParameter(ScaleBytes $bytes): array + { + $name = $this->readString($bytes); + $hasType = $bytes->readByte(); + + return [ + 'name' => $name, + 'type' => $hasType ? $this->readCompact($bytes) : null, + ]; + } + + /** + * Parse type definition variant. + */ + private function parseTypeDef(ScaleBytes $bytes): array + { + $kind = $bytes->readByte(); + + return match ($kind) { + 0 => ['composite' => ['fields' => $this->parseFields($bytes)]], + 1 => ['variant' => $this->parseVariantDef($bytes)], + 2 => ['sequence' => ['type' => $this->readCompact($bytes)]], + 3 => ['array' => ['type' => $this->readCompact($bytes), 'len' => $this->readCompact($bytes)]], + 4 => ['tuple' => $this->parseTuple($bytes)], + 5 => ['primitive' => $this->parsePrimitive($bytes)], + 6 => ['compact' => ['type' => $this->readCompact($bytes)]], + 7 => ['bitsequence' => true], + default => throw new ScaleDecodeException("Unknown type def kind: $kind"), + }; + } + + /** + * Parse variant definition. + */ + private function parseVariantDef(ScaleBytes $bytes): array + { + $variantCount = $this->readCompact($bytes); + $variants = []; + + for ($i = 0; $i < $variantCount; $i++) { + $variants[] = [ + 'name' => $this->readString($bytes), + 'index' => $bytes->readByte(), + 'fields' => $this->parseFields($bytes), + 'docs' => $this->parseDocs($bytes), + ]; + } + + return ['variants' => $variants]; + } + + /** + * Parse fields. + */ + private function parseFields(ScaleBytes $bytes): array + { + $count = $this->readCompact($bytes); + $fields = []; + + for ($i = 0; $i < $count; $i++) { + $field = [ + 'name' => null, + 'type' => $this->readCompact($bytes), + 'typeName' => null, + ]; + + $hasName = $bytes->readByte(); + if ($hasName) { + $field['name'] = $this->readString($bytes); + } + + $hasTypeName = $bytes->readByte(); + if ($hasTypeName) { + $field['typeName'] = $this->readString($bytes); + } + + $fields[] = $field; + } + + return $fields; + } + + /** + * Parse tuple type. + */ + private function parseTuple(ScaleBytes $bytes): array + { + $count = $this->readCompact($bytes); + $types = []; + for ($i = 0; $i < $count; $i++) { + $types[] = $this->readCompact($bytes); + } + return $types; + } + + /** + * Parse primitive type. + */ + private function parsePrimitive(ScaleBytes $bytes): string + { + $kind = $bytes->readByte(); + + return match ($kind) { + 0 => 'bool', + 1 => 'char', + 2 => 'str', + 3 => 'U8', + 4 => 'U16', + 5 => 'U32', + 6 => 'U64', + 7 => 'U128', + 8 => 'I8', + 9 => 'I16', + 10 => 'I32', + 11 => 'I64', + 12 => 'I128', + default => throw new ScaleDecodeException("Unknown primitive kind: $kind"), + }; + } + + /** + * Parse documentation. + */ + private function parseDocs(ScaleBytes $bytes): array + { + $count = $this->readCompact($bytes); + $docs = []; + for ($i = 0; $i < $count; $i++) { + $docs[] = $this->readString($bytes); + } + return $docs; + } + + /** + * Parse pallet v14+. + */ + private function parsePalletV14(ScaleBytes $bytes, int $index, ?Metadata $metadata = null): Pallet + { + $name = $this->readString($bytes); + + // Parse storage + $storage = []; + $hasStorage = $bytes->readByte(); + if ($hasStorage) { + $storagePrefix = $this->readString($bytes); + $storageCount = $this->readCompact($bytes); + for ($i = 0; $i < $storageCount; $i++) { + $storage[] = $this->parseStorageEntry($bytes); + } + } + + // Parse calls + $calls = []; + $hasCalls = $bytes->readByte(); + if ($hasCalls) { + $callType = $this->readCompact($bytes); + $calls = ['type' => $callType]; + } + + // Parse events + $events = []; + $hasEvents = $bytes->readByte(); + if ($hasEvents) { + $eventType = $this->readCompact($bytes); + $events = ['type' => $eventType]; + } + + // Parse constants + $constantCount = $this->readCompact($bytes); + $constants = []; + for ($i = 0; $i < $constantCount; $i++) { + $constants[] = $this->parseConstant($bytes); + } + + // Parse errors + $errors = []; + $hasErrors = $bytes->readByte(); + if ($hasErrors) { + $errorType = $this->readCompact($bytes); + $errors = ['type' => $errorType]; + } + + // Parse index (v14+) + $palletIndex = $bytes->readByte(); + + return new Pallet( + name: $name, + index: $palletIndex, + storage: $storage, + calls: $calls, + events: $events, + errors: $errors, + constants: $constants, + ); + } + + /** + * Parse storage entry. + */ + private function parseStorageEntry(ScaleBytes $bytes): array + { + $name = $this->readString($bytes); + $modifier = $bytes->readByte(); + $type = $this->parseStorageEntryType($bytes); + $fallback = $this->readBytes($bytes); + $docs = $this->parseDocs($bytes); + + return [ + 'name' => $name, + 'modifier' => $modifier, + 'type' => $type, + 'fallback' => $fallback, + 'docs' => $docs, + ]; + } + + /** + * Parse storage entry type. + */ + private function parseStorageEntryType(ScaleBytes $bytes): array + { + $kind = $bytes->readByte(); + + return match ($kind) { + 0 => ['plain' => $this->readCompact($bytes)], + 1 => [ + 'map' => [ + 'hashers' => $this->parseHashers($bytes), + 'key' => $this->readCompact($bytes), + 'value' => $this->readCompact($bytes), + ], + ], + default => throw new ScaleDecodeException("Unknown storage entry type: $kind"), + }; + } + + /** + * Parse hashers. + */ + private function parseHashers(ScaleBytes $bytes): array + { + $count = $this->readCompact($bytes); + $hashers = []; + for ($i = 0; $i < $count; $i++) { + $hashers[] = $this->parseHasher($bytes); + } + return $hashers; + } + + /** + * Parse hasher. + */ + private function parseHasher(ScaleBytes $bytes): string + { + $kind = $bytes->readByte(); + + return match ($kind) { + 0 => 'Blake2_128', + 1 => 'Blake2_256', + 2 => 'Blake2_128Concat', + 3 => 'Twox128', + 4 => 'Twox256', + 5 => 'Twox64Concat', + 6 => 'Identity', + default => "Hasher_$kind", + }; + } + + /** + * Parse function (call/event/error). + */ + private function parseFunction(ScaleBytes $bytes): array + { + $name = $this->readString($bytes); + + // Parse arguments + $argCount = $this->readCompact($bytes); + $args = []; + for ($i = 0; $i < $argCount; $i++) { + $args[] = [ + 'name' => $this->readString($bytes), + 'type' => $this->readString($bytes), + ]; + } + + $docs = $this->parseDocs($bytes); + + return [ + 'name' => $name, + 'args' => $args, + 'docs' => $docs, + ]; + } + + /** + * Parse constant. + */ + private function parseConstant(ScaleBytes $bytes): array + { + return [ + 'name' => $this->readString($bytes), + 'type' => $this->readCompact($bytes), + 'value' => $this->readBytes($bytes), + 'docs' => $this->parseDocs($bytes), + ]; + } + + /** + * Parse extrinsic metadata. + */ + private function parseExtrinsic(ScaleBytes $bytes): array + { + return [ + 'version' => $bytes->readByte(), + 'addressType' => $this->readCompact($bytes), + 'callType' => $this->readCompact($bytes), + 'signatureType' => $this->readCompact($bytes), + 'extraType' => $this->readCompact($bytes), + ]; + } + + /** + * Parse runtime API. + */ + private function parseRuntimeApi(ScaleBytes $bytes): array + { + $name = $this->readString($bytes); + $methodCount = $this->readCompact($bytes); + $methods = []; + + for ($i = 0; $i < $methodCount; $i++) { + $methods[] = [ + 'name' => $this->readString($bytes), + 'inputs' => $this->readCompact($bytes), + 'output' => $this->readCompact($bytes), + 'docs' => $this->parseDocs($bytes), + ]; + } + + return [ + 'name' => $name, + 'methods' => $methods, + ]; + } + + /** + * Parse outer event types. + */ + private function parseOuterEvent(ScaleBytes $bytes): array + { + $count = $this->readCompact($bytes); + $events = []; + + for ($i = 0; $i < $count; $i++) { + $events[] = [ + 'name' => $this->readString($bytes), + 'type' => $this->readCompact($bytes), + ]; + } + + return $events; + } + + /** + * Read a compact integer. + */ + private function readCompact(ScaleBytes $bytes): int + { + $first = $bytes->readByte(); + $mode = $first & 0x03; + + return match ($mode) { + 0 => $first >> 2, + 1 => ($first >> 2) | ($bytes->readByte() << 6), + 2 => ($first >> 2) | ($bytes->readByte() << 6) | ($bytes->readByte() << 14) | ($bytes->readByte() << 22), + 3 => throw new ScaleDecodeException('Large compact not supported for type IDs'), + }; + } + + /** + * Read a string. + */ + private function readString(ScaleBytes $bytes): string + { + $length = $this->readCompact($bytes); + $rawBytes = $bytes->readBytes($length); + return pack('C*', ...$rawBytes); + } + + /** + * Read bytes (length-prefixed). + */ + private function readBytes(ScaleBytes $bytes): array + { + $length = $this->readCompact($bytes); + return $bytes->readBytes($length); + } + + /** + * Read U32. + */ + private function readU32(ScaleBytes $bytes): int + { + $b = $bytes->readBytes(4); + return $b[0] | ($b[1] << 8) | ($b[2] << 16) | ($b[3] << 24); + } + + /** + * Clear the metadata cache. + */ + public static function clearCache(): void + { + self::$cache = []; + } +} diff --git a/src/Metadata/MetadataVersion.php b/src/Metadata/MetadataVersion.php new file mode 100644 index 0000000..dcc2e6a --- /dev/null +++ b/src/Metadata/MetadataVersion.php @@ -0,0 +1,46 @@ + self::V12, + 13 => self::V13, + 14 => self::V14, + 15 => self::V15, + default => null, + }; + } + + /** + * Check if this version supports portable types. + */ + public function supportsPortableTypes(): bool + { + return $this->value >= 14; + } + + /** + * Check if this version supports apis field. + */ + public function supportsApis(): bool + { + return $this->value >= 15; + } +} diff --git a/src/Metadata/Pallet.php b/src/Metadata/Pallet.php new file mode 100644 index 0000000..cc7e71a --- /dev/null +++ b/src/Metadata/Pallet.php @@ -0,0 +1,85 @@ +storage as $entry) { + if (($entry['name'] ?? null) === $name) { + return $entry; + } + } + return null; + } + + /** + * Get a call by name. + */ + public function getCall(string $name): ?array + { + foreach ($this->calls as $call) { + if (($call['name'] ?? null) === $name) { + return $call; + } + } + return null; + } + + /** + * Get an event by index. + */ + public function getEvent(int $index): ?array + { + return $this->events[$index] ?? null; + } + + /** + * Get an error by index. + */ + public function getError(int $index): ?array + { + return $this->errors[$index] ?? null; + } + + /** + * Get a constant by name. + */ + public function getConstant(string $name): ?array + { + foreach ($this->constants as $constant) { + if (($constant['name'] ?? null) === $name) { + return $constant; + } + } + return null; + } +} diff --git a/src/Metadata/TypeDefinition.php b/src/Metadata/TypeDefinition.php new file mode 100644 index 0000000..f067929 --- /dev/null +++ b/src/Metadata/TypeDefinition.php @@ -0,0 +1,149 @@ +def) ?? 'unknown'; + } + + /** + * Check if this is a composite type (struct). + */ + public function isComposite(): bool + { + return $this->getKind() === 'composite'; + } + + /** + * Check if this is a variant type (enum). + */ + public function isVariant(): bool + { + return $this->getKind() === 'variant'; + } + + /** + * Check if this is a sequence type (Vec). + */ + public function isSequence(): bool + { + return $this->getKind() === 'sequence'; + } + + /** + * Check if this is an array type. + */ + public function isArray(): bool + { + return $this->getKind() === 'array'; + } + + /** + * Check if this is a tuple type. + */ + public function isTuple(): bool + { + return $this->getKind() === 'tuple'; + } + + /** + * Check if this is a primitive type. + */ + public function isPrimitive(): bool + { + return $this->getKind() === 'primitive'; + } + + /** + * Check if this is a compact type. + */ + public function isCompact(): bool + { + return $this->getKind() === 'compact'; + } + + /** + * Check if this is a bit sequence type. + */ + public function isBitSequence(): bool + { + return $this->getKind() === 'bitsequence'; + } + + /** + * Get composite fields. + */ + public function getFields(): array + { + return $this->def['composite']['fields'] ?? []; + } + + /** + * Get enum variants. + */ + public function getVariants(): array + { + return $this->def['variant']['variants'] ?? []; + } + + /** + * Get sequence element type. + */ + public function getElementType(): ?int + { + return $this->def['sequence']['type'] ?? null; + } + + /** + * Get array element type and length. + */ + public function getArrayInfo(): array + { + return [ + 'type' => $this->def['array']['type'] ?? null, + 'len' => $this->def['array']['len'] ?? 0, + ]; + } + + /** + * Get tuple element types. + */ + public function getTupleTypes(): array + { + return $this->def['tuple'] ?? []; + } + + /** + * Get primitive type. + */ + public function getPrimitiveType(): ?string + { + return $this->def['primitive'] ?? null; + } +} diff --git a/tests/Metadata/MetadataParserTest.php b/tests/Metadata/MetadataParserTest.php new file mode 100644 index 0000000..a2d3476 --- /dev/null +++ b/tests/Metadata/MetadataParserTest.php @@ -0,0 +1,296 @@ +parser = new MetadataParser(); + } + + public function testParseMetadataVersion14(): void + { + // Minimal v14 metadata + // magic "meta" + version 14 + types(0) + pallets(0) + extrinsic + $hex = '0x6d657461' // magic "meta" in little-endian + . '0e' // version 14 + . '00' // 0 types + . '00' // 0 pallets + . '04' // extrinsic version 4 + . '00' // address type + . '00' // call type + . '00' // signature type + . '00'; // extra type + + $metadata = $this->parser->parse($hex); + + $this->assertEquals(MetadataVersion::V14, $metadata->version); + $this->assertEmpty($metadata->getTypes()); + $this->assertEmpty($metadata->getPallets()); + } + + public function testParseMetadataVersion15(): void + { + // Minimal v15 metadata + $hex = '0x6d657461' // magic "meta" + . '0f' // version 15 + . '00' // 0 types + . '00' // 0 pallets + . '04' // extrinsic version + . '00' // address type + . '00' // call type + . '00' // signature type + . '00' // extra type + . '00'; // 0 APIs + + $metadata = $this->parser->parse($hex); + + $this->assertEquals(MetadataVersion::V15, $metadata->version); + } + + public function testMetadataCaching(): void + { + $hex = '0x6d6574610e0000000400000000'; + + // First parse + $metadata1 = $this->parser->parse($hex); + + // Second parse (should be cached) + $metadata2 = $this->parser->parse($hex); + + // Should be same instance (cached) + $this->assertSame($metadata1, $metadata2); + + // Clear cache and parse again + MetadataParser::clearCache(); + $metadata3 = $this->parser->parse($hex); + + // Should be different instance after cache clear + $this->assertNotSame($metadata1, $metadata3); + } + + public function testMetadataVersionSupport(): void + { + $v14 = MetadataVersion::V14; + $this->assertTrue($v14->supportsPortableTypes()); + $this->assertFalse($v14->supportsApis()); + + $v15 = MetadataVersion::V15; + $this->assertTrue($v15->supportsPortableTypes()); + $this->assertTrue($v15->supportsApis()); + } + + public function testMetadataVersionFromInt(): void + { + $this->assertEquals(MetadataVersion::V12, MetadataVersion::fromInt(12)); + $this->assertEquals(MetadataVersion::V13, MetadataVersion::fromInt(13)); + $this->assertEquals(MetadataVersion::V14, MetadataVersion::fromInt(14)); + $this->assertEquals(MetadataVersion::V15, MetadataVersion::fromInt(15)); + $this->assertNull(MetadataVersion::fromInt(99)); + } + + public function testInvalidMagicNumber(): void + { + $this->expectException(\Substrate\ScaleCodec\Exception\ScaleDecodeException::class); + $this->expectExceptionMessage('Invalid metadata magic number'); + + $hex = '0x00000000' . '0e' . '00'; // Wrong magic + $this->parser->parse($hex); + } + + public function testUnsupportedVersion(): void + { + $this->expectException(\Substrate\ScaleCodec\Exception\ScaleDecodeException::class); + $this->expectExceptionMessage('Unsupported metadata version'); + + $hex = '0x6d657461' . 'ff' . '00'; // Version 255 + $this->parser->parse($hex); + } + + public function testTypeDefinitionMethods(): void + { + // Composite type (struct) + $compositeDef = new TypeDefinition( + id: 0, + path: 'Test::Struct', + def: ['composite' => ['fields' => []]] + ); + + $this->assertTrue($compositeDef->isComposite()); + $this->assertFalse($compositeDef->isVariant()); + $this->assertEquals('composite', $compositeDef->getKind()); + $this->assertEquals([], $compositeDef->getFields()); + + // Variant type (enum) + $variantDef = new TypeDefinition( + id: 1, + path: 'Test::Enum', + def: ['variant' => ['variants' => [['name' => 'A', 'index' => 0]]]] + ); + + $this->assertTrue($variantDef->isVariant()); + $this->assertEquals('variant', $variantDef->getKind()); + $this->assertCount(1, $variantDef->getVariants()); + + // Sequence type + $sequenceDef = new TypeDefinition( + id: 2, + def: ['sequence' => ['type' => 0]] + ); + + $this->assertTrue($sequenceDef->isSequence()); + $this->assertEquals(0, $sequenceDef->getElementType()); + + // Array type + $arrayDef = new TypeDefinition( + id: 3, + def: ['array' => ['type' => 0, 'len' => 32]] + ); + + $this->assertTrue($arrayDef->isArray()); + $arrInfo = $arrayDef->getArrayInfo(); + $this->assertEquals(0, $arrInfo['type']); + $this->assertEquals(32, $arrInfo['len']); + + // Tuple type + $tupleDef = new TypeDefinition( + id: 4, + def: ['tuple' => [0, 1, 2]] + ); + + $this->assertTrue($tupleDef->isTuple()); + $this->assertEquals([0, 1, 2], $tupleDef->getTupleTypes()); + + // Primitive type + $primitiveDef = new TypeDefinition( + id: 5, + def: ['primitive' => 'U32'] + ); + + $this->assertTrue($primitiveDef->isPrimitive()); + $this->assertEquals('U32', $primitiveDef->getPrimitiveType()); + + // Compact type + $compactDef = new TypeDefinition( + id: 6, + def: ['compact' => ['type' => 0]] + ); + + $this->assertTrue($compactDef->isCompact()); + + // BitSequence type + $bitseqDef = new TypeDefinition( + id: 7, + def: ['bitsequence' => true] + ); + + $this->assertTrue($bitseqDef->isBitSequence()); + } + + public function testPalletMethods(): void + { + $pallet = new Pallet( + name: 'System', + index: 0, + storage: [['name' => 'Account'], ['name' => 'BlockHash']], + calls: [['name' => 'remark'], ['name' => 'setHeapPages']], + events: [['name' => 'ExtrinsicSuccess'], ['name' => 'ExtrinsicFailed']], + errors: [['name' => 'BadOrigin']], + constants: [['name' => 'BlockHashCount']], + ); + + $this->assertEquals('System', $pallet->name); + $this->assertEquals(0, $pallet->index); + + // Test storage lookup + $this->assertNotNull($pallet->getStorage('Account')); + $this->assertNotNull($pallet->getStorage('BlockHash')); + $this->assertNull($pallet->getStorage('NonExistent')); + + // Test call lookup + $this->assertNotNull($pallet->getCall('remark')); + $this->assertNull($pallet->getCall('nonExistent')); + + // Test event lookup + $this->assertNotNull($pallet->getEvent(0)); + $this->assertNull($pallet->getEvent(99)); + + // Test error lookup + $this->assertNotNull($pallet->getError(0)); + $this->assertNull($pallet->getError(99)); + + // Test constant lookup + $this->assertNotNull($pallet->getConstant('BlockHashCount')); + $this->assertNull($pallet->getConstant('NonExistent')); + } + + public function testMetadataGetTypeByName(): void + { + $metadata = new Metadata(MetadataVersion::V14); + + $type = new TypeDefinition( + id: 0, + path: 'frame_system::AccountInfo', + def: ['composite' => ['fields' => []]] + ); + $metadata->addType($type); + + // Look up by full path + $this->assertEquals(0, $metadata->getTypeIdByName('frame_system::AccountInfo')); + + // Look up by last segment + $this->assertEquals(0, $metadata->getTypeIdByName('AccountInfo')); + + // Non-existent type + $this->assertNull($metadata->getTypeIdByName('NonExistent')); + } + + public function testMetadataGetExtrinsicInfo(): void + { + $metadata = new Metadata( + version: MetadataVersion::V14, + extrinsic: [ + 'version' => 4, + 'addressType' => 0, + 'callType' => 1, + 'signatureType' => 2, + 'extraType' => 3, + ] + ); + + $this->assertEquals(4, $metadata->getExtrinsicVersion()); + $this->assertEquals(0, $metadata->getExtrinsicAddressType()); + $this->assertEquals(1, $metadata->getExtrinsicCallType()); + $this->assertEquals(2, $metadata->getExtrinsicSignatureType()); + $this->assertEquals(3, $metadata->getExtrinsicExtraType()); + } + + public function testMetadataPalletLookup(): void + { + $metadata = new Metadata(MetadataVersion::V14); + + $pallet1 = new Pallet(name: 'System', index: 0); + $pallet2 = new Pallet(name: 'Balances', index: 1); + + $metadata->addPallet($pallet1); + $metadata->addPallet($pallet2); + + // Lookup by name + $this->assertSame($pallet1, $metadata->getPallet('System')); + $this->assertSame($pallet2, $metadata->getPallet('Balances')); + $this->assertNull($metadata->getPallet('NonExistent')); + + // Lookup by index + $this->assertSame($pallet1, $metadata->getPalletByIndex(0)); + $this->assertSame($pallet2, $metadata->getPalletByIndex(1)); + $this->assertNull($metadata->getPalletByIndex(99)); + } +}