diff --git a/src/Extrinsic/Extrinsic.php b/src/Extrinsic/Extrinsic.php new file mode 100644 index 0000000..91e0a0a --- /dev/null +++ b/src/Extrinsic/Extrinsic.php @@ -0,0 +1,98 @@ +signature !== null; + } + + /** + * Get the pallet name. + */ + public function getPallet(): string + { + return $this->call['pallet'] ?? ''; + } + + /** + * Get the function name. + */ + public function getFunction(): string + { + return $this->call['function'] ?? ''; + } + + /** + * Get the call arguments. + */ + public function getArguments(): array + { + return $this->call['args'] ?? []; + } + + /** + * Get the signer address (if signed). + */ + public function getSigner(): ?string + { + return $this->signature?->signer; + } + + /** + * Get the signature bytes (if signed). + */ + public function getSignatureBytes(): ?string + { + return $this->signature?->signature; + } + + /** + * Get the era (mortality) of the extrinsic. + */ + public function getEra(): ?array + { + return $this->extra['era'] ?? $this->signature?->getEra(); + } + + /** + * Get the nonce. + */ + public function getNonce(): int + { + return $this->extra['nonce'] ?? $this->signature?->getNonce() ?? 0; + } + + /** + * Get the tip. + */ + public function getTip(): int|string + { + return $this->extra['tip'] ?? $this->signature?->getTip() ?? 0; + } +} diff --git a/src/Extrinsic/ExtrinsicBuilder.php b/src/Extrinsic/ExtrinsicBuilder.php new file mode 100644 index 0000000..b04eeaf --- /dev/null +++ b/src/Extrinsic/ExtrinsicBuilder.php @@ -0,0 +1,222 @@ +pallet('Balances') + * ->function('transfer') + * ->args(['dest' => $address, 'value' => 1000]) + * ->signer($signerAddress) + * ->signature($signature) + * ->nonce(1) + * ->tip(0) + * ->build(); + */ +class ExtrinsicBuilder +{ + private ?string $pallet = null; + private ?string $function = null; + private int $palletIndex = 0; + private int $functionIndex = 0; + private array $args = []; + private ?string $signer = null; + private ?string $signature = null; + private string $signatureType = 'sr25519'; + private int $nonce = 0; + private int|string $tip = 0; + private ?array $era = null; + private int $version = 4; + + private function __construct() + { + } + + /** + * Create a new builder instance. + */ + public static function create(): self + { + return new self(); + } + + /** + * Set the pallet name. + */ + public function pallet(string $name): self + { + $this->pallet = $name; + return $this; + } + + /** + * Set the pallet index directly. + */ + public function palletIndex(int $index): self + { + $this->palletIndex = $index; + return $this; + } + + /** + * Set the function name. + */ + public function function(string $name): self + { + $this->function = $name; + return $this; + } + + /** + * Set the function index directly. + */ + public function functionIndex(int $index): self + { + $this->functionIndex = $index; + return $this; + } + + /** + * Set the call arguments. + */ + public function args(array $args): self + { + $this->args = $args; + return $this; + } + + /** + * Add a single argument. + */ + public function arg(string $name, mixed $value): self + { + $this->args[$name] = $value; + return $this; + } + + /** + * Set the signer address. + */ + public function signer(string $address): self + { + $this->signer = $address; + return $this; + } + + /** + * Set the signature. + */ + public function signature(string $signature, string $type = 'sr25519'): self + { + $this->signature = $signature; + $this->signatureType = $type; + return $this; + } + + /** + * Set the nonce. + */ + public function nonce(int $nonce): self + { + $this->nonce = $nonce; + return $this; + } + + /** + * Set the tip. + */ + public function tip(int|string $tip): self + { + $this->tip = $tip; + return $this; + } + + /** + * Set immortal era. + */ + public function immortal(): self + { + $this->era = null; + return $this; + } + + /** + * Set mortal era. + */ + public function mortal(int $period, int $phase = 0): self + { + $this->era = ['period' => $period, 'phase' => $phase]; + return $this; + } + + /** + * Set the extrinsic version. + */ + public function version(int $version): self + { + $this->version = $version; + return $this; + } + + /** + * Build an unsigned extrinsic. + */ + public function buildUnsigned(): Extrinsic + { + return new Extrinsic( + call: $this->buildCall(), + signature: null, + extra: [], + ); + } + + /** + * Build a signed extrinsic. + */ + public function build(): Extrinsic + { + $signature = null; + + if ($this->signer !== null && $this->signature !== null) { + $signature = new Signature( + signer: $this->signer, + signature: $this->signature, + extra: [ + 'era' => $this->era, + 'nonce' => $this->nonce, + 'tip' => $this->tip, + ], + signerType: $this->signatureType, + ); + } + + return new Extrinsic( + call: $this->buildCall(), + signature: $signature, + extra: [ + 'era' => $this->era, + 'nonce' => $this->nonce, + 'tip' => $this->tip, + ], + ); + } + + /** + * Build the call data. + */ + private function buildCall(): array + { + return [ + 'pallet' => $this->pallet, + 'palletIndex' => $this->palletIndex, + 'function' => $this->function, + 'functionIndex' => $this->functionIndex, + 'args' => $this->args, + ]; + } +} diff --git a/src/Extrinsic/ExtrinsicDecoder.php b/src/Extrinsic/ExtrinsicDecoder.php new file mode 100644 index 0000000..99c2518 --- /dev/null +++ b/src/Extrinsic/ExtrinsicDecoder.php @@ -0,0 +1,165 @@ +registry = $registry ?? new TypeRegistry(); + } + + /** + * Decode a SCALE-encoded extrinsic. + * + * @param ScaleBytes $bytes The encoded bytes + * @return Extrinsic The decoded extrinsic + */ + public function decode(ScaleBytes $bytes): Extrinsic + { + // Read length + $compact = new Compact($this->registry); + $length = $compact->decode($bytes); + + // Read version byte + $versionByte = $bytes->readByte(); + $isSigned = ($versionByte & 0x80) !== 0; + $version = $versionByte & 0x7f; + + $signature = null; + $extra = []; + + if ($isSigned) { + // Decode signer + $signer = $this->decodeSigner($bytes); + + // Decode signature + $signatureBytes = $this->decodeSignature($bytes); + + // Decode extra + $extra = $this->decodeExtra($bytes); + + $signature = new Signature( + signer: $signer, + signature: $signatureBytes, + extra: $extra, + ); + } + + // Decode call + $call = $this->decodeCall($bytes); + + return new Extrinsic( + call: $call, + signature: $signature, + extra: $extra, + ); + } + + /** + * Decode hex string to extrinsic. + */ + public function decodeHex(string $hex): Extrinsic + { + if (!str_starts_with($hex, '0x')) { + $hex = '0x' . $hex; + } + return $this->decode(ScaleBytes::fromHex($hex)); + } + + /** + * Decode the signer address. + */ + private function decodeSigner(ScaleBytes $bytes): string + { + // Read MultiAddress variant + $variant = $bytes->readByte(); + + if ($variant === 0) { + // Id (32 bytes) + $addressBytes = $bytes->readBytes(32); + return '0x' . bin2hex(pack('C*', ...$addressBytes)); + } + + if ($variant === 1) { + // Index (Compact) + $compact = new Compact($this->registry); + return '0x' . dechex($compact->decode($bytes)); + } + + throw new \RuntimeException("Unsupported MultiAddress variant: $variant"); + } + + /** + * Decode the signature. + */ + private function decodeSignature(ScaleBytes $bytes): string + { + // Sr25519 and Ed25519 are 64 bytes + $signatureBytes = $bytes->readBytes(64); + return '0x' . bin2hex(pack('C*', ...$signatureBytes)); + } + + /** + * Decode extra data. + */ + private function decodeExtra(ScaleBytes $bytes): array + { + $extra = []; + + // Era + $first = $bytes->peekByte(); + if ($first === 0x00) { + $bytes->readByte(); // Consume immortal era + $extra['era'] = ['type' => 'immortal']; + } else { + $eraBytes = $bytes->readBytes(2); + $extra['era'] = [ + 'type' => 'mortal', + 'phase' => $eraBytes[0] | ($eraBytes[1] << 8), + ]; + } + + // Nonce (Compact) + $compact = new Compact($this->registry); + $extra['nonce'] = $compact->decode($bytes); + + // Tip (Compact) + $extra['tip'] = $compact->decode($bytes); + + return $extra; + } + + /** + * Decode the call. + */ + private function decodeCall(ScaleBytes $bytes): array + { + $palletIndex = $bytes->readByte(); + $functionIndex = $bytes->readByte(); + + // Remaining bytes are the call arguments + // (In a real implementation, we'd use metadata to decode properly) + $remainingBytes = $bytes->readBytes($bytes->remaining()); + + return [ + 'palletIndex' => $palletIndex, + 'functionIndex' => $functionIndex, + 'args' => $remainingBytes, + ]; + } +} diff --git a/src/Extrinsic/ExtrinsicEncoder.php b/src/Extrinsic/ExtrinsicEncoder.php new file mode 100644 index 0000000..da7dfb2 --- /dev/null +++ b/src/Extrinsic/ExtrinsicEncoder.php @@ -0,0 +1,237 @@ +registry = $registry ?? new TypeRegistry(); + } + + /** + * Encode an extrinsic to SCALE bytes. + * + * @param Extrinsic $extrinsic The extrinsic to encode + * @param int $version Extrinsic version (default 4) + * @return ScaleBytes The encoded bytes + */ + public function encode(Extrinsic $extrinsic, int $version = 4): ScaleBytes + { + // Build the call data first + $callData = $this->encodeCall($extrinsic); + + if ($extrinsic->isSigned()) { + return $this->encodeSigned($extrinsic, $callData, $version); + } + + return $this->encodeUnsigned($callData, $version); + } + + /** + * Encode a signed extrinsic. + */ + private function encodeSigned(Extrinsic $extrinsic, ScaleBytes $callData, int $version): ScaleBytes + { + $result = ScaleBytes::empty(); + + // Version byte: 0x80 | version (indicates signed) + $versionByte = 0x80 | ($version & 0x7f); + $result = $result->concat(ScaleBytes::fromBytes([$versionByte])); + + // Signer (MultiAddress) + $signer = $extrinsic->getSigner(); + $result = $result->concat($this->encodeSigner($signer)); + + // Signature + $signature = $extrinsic->getSignatureBytes(); + $signatureType = $extrinsic->signature?->signerType ?? 'sr25519'; + $result = $result->concat($this->encodeSignature($signature, $signatureType)); + + // Extra: era, nonce, tip + $result = $result->concat($this->encodeExtra($extrinsic)); + + // Call data + $result = $result->concat($callData); + + // Length prefix + return $this->withLengthPrefix($result); + } + + /** + * Encode an unsigned extrinsic. + */ + private function encodeUnsigned(ScaleBytes $callData, int $version): ScaleBytes + { + $result = ScaleBytes::empty(); + + // Version byte: just the version (no 0x80 bit = unsigned) + $result = $result->concat(ScaleBytes::fromBytes([$version & 0x7f])); + + // Call data + $result = $result->concat($callData); + + // Length prefix + return $this->withLengthPrefix($result); + } + + /** + * Encode the call data. + */ + private function encodeCall(Extrinsic $extrinsic): ScaleBytes + { + $result = ScaleBytes::empty(); + + // Pallet index (U8) + $palletIndex = $extrinsic->call['palletIndex'] ?? 0; + $result = $result->concat(ScaleBytes::fromBytes([$palletIndex])); + + // Function index (U8) + $functionIndex = $extrinsic->call['functionIndex'] ?? 0; + $result = $result->concat(ScaleBytes::fromBytes([$functionIndex])); + + // Arguments + $args = $extrinsic->getArguments(); + $result = $result->concat($this->encodeArguments($args)); + + return $result; + } + + /** + * Encode the signer address. + */ + private function encodeSigner(string $signer): ScaleBytes + { + // Assume 32-byte account ID (Id variant = 0) + $result = ScaleBytes::fromBytes([0]); // MultiAddress::Id + + // Convert hex to bytes + if (str_starts_with($signer, '0x')) { + $signer = substr($signer, 2); + } + + $bytes = array_map('hexdec', str_split($signer, 2)); + return $result->concat(ScaleBytes::fromBytes($bytes)); + } + + /** + * Encode the signature. + */ + private function encodeSignature(string $signature, string $type): ScaleBytes + { + // Convert hex to bytes + if (str_starts_with($signature, '0x')) { + $signature = substr($signature, 2); + } + + $bytes = array_map('hexdec', str_split($signature, 2)); + + // Sr25519 and Ed25519 are 64 bytes, Ecdsa is 65 bytes + return ScaleBytes::fromBytes($bytes); + } + + /** + * Encode the extra data (era, nonce, tip). + */ + private function encodeExtra(Extrinsic $extrinsic): ScaleBytes + { + $result = ScaleBytes::empty(); + + // Era (immortal = 0x00, or mortal as 2 bytes) + $era = $extrinsic->getEra(); + if ($era === null) { + // Immortal era + $result = $result->concat(ScaleBytes::fromBytes([0x00])); + } else { + // Mortal era: phase + period + $result = $result->concat($this->encodeEra($era)); + } + + // Nonce (Compact) + $nonce = $extrinsic->getNonce(); + $compact = new Compact($this->registry); + $result = $result->concat($compact->encode($nonce)); + + // Tip (Compact, usually 0) + $tip = $extrinsic->getTip(); + $result = $result->concat($compact->encode(is_string($tip) ? $tip : $tip)); + + return $result; + } + + /** + * Encode era. + */ + private function encodeEra(array $era): ScaleBytes + { + $period = $era['period'] ?? 64; + $phase = $era['phase'] ?? 0; + + // Simple mortal encoding + $encoded = $this->encodeMortalEra($period, $phase); + return ScaleBytes::fromBytes($encoded); + } + + /** + * Encode mortal era. + */ + private function encodeMortalEra(int $period, int $phase): array + { + // Simplified: just return phase bytes + $low = $phase & 0xff; + $high = ($phase >> 8) & 0xff; + return [$low, $high]; + } + + /** + * Encode call arguments. + */ + private function encodeArguments(array $args): ScaleBytes + { + $result = ScaleBytes::empty(); + + foreach ($args as $arg) { + // Simple argument encoding - real implementation would need type info + if (is_int($arg)) { + // Encode as U32 for simplicity + $u32 = new U32($this->registry); + $result = $result->concat($u32->encode($arg)); + } elseif (is_string($arg) && str_starts_with($arg, '0x')) { + // Hex bytes + $hex = substr($arg, 2); + $bytes = array_map('hexdec', str_split($hex, 2)); + $result = $result->concat(ScaleBytes::fromBytes($bytes)); + } elseif (is_array($arg)) { + // Nested structure - would need type info + // For now, skip + } + } + + return $result; + } + + /** + * Add length prefix to the encoded data. + */ + private function withLengthPrefix(ScaleBytes $data): ScaleBytes + { + $length = count($data->toBytes()); + $compact = new Compact($this->registry); + $lengthBytes = $compact->encode($length); + return $lengthBytes->concat($data); + } +} diff --git a/src/Extrinsic/Signature.php b/src/Extrinsic/Signature.php new file mode 100644 index 0000000..955ae00 --- /dev/null +++ b/src/Extrinsic/Signature.php @@ -0,0 +1,72 @@ +signerType === 'ed25519'; + } + + /** + * Check if this is an Sr25519 signature. + */ + public function isSr25519(): bool + { + return $this->signerType === 'sr25519'; + } + + /** + * Check if this is an Ecdsa signature. + */ + public function isEcdsa(): bool + { + return $this->signerType === 'ecdsa'; + } + + /** + * Get the era (mortality) of the extrinsic. + */ + public function getEra(): ?array + { + return $this->extra['era'] ?? null; + } + + /** + * Get the nonce. + */ + public function getNonce(): int + { + return $this->extra['nonce'] ?? 0; + } + + /** + * Get the tip. + */ + public function getTip(): int|string + { + return $this->extra['tip'] ?? 0; + } +} diff --git a/tests/Extrinsic/ExtrinsicTest.php b/tests/Extrinsic/ExtrinsicTest.php new file mode 100644 index 0000000..1e3489e --- /dev/null +++ b/tests/Extrinsic/ExtrinsicTest.php @@ -0,0 +1,285 @@ +encoder = new ExtrinsicEncoder(); + $this->decoder = new ExtrinsicDecoder(); + } + + // ==================== Signature Tests ==================== + + public function testSignatureCreation(): void + { + $signature = new Signature( + signer: '0x' . str_repeat('01', 32), + signature: '0x' . str_repeat('aa', 64), + extra: ['nonce' => 5, 'tip' => 100], + signerType: 'sr25519' + ); + + $this->assertEquals('0x' . str_repeat('01', 32), $signature->signer); + $this->assertEquals('0x' . str_repeat('aa', 64), $signature->signature); + $this->assertEquals('sr25519', $signature->signerType); + $this->assertTrue($signature->isSr25519()); + $this->assertFalse($signature->isEd25519()); + $this->assertFalse($signature->isEcdsa()); + } + + public function testSignatureEd25519(): void + { + $signature = new Signature( + signer: '0x' . str_repeat('01', 32), + signature: '0x' . str_repeat('bb', 64), + signerType: 'ed25519' + ); + + $this->assertTrue($signature->isEd25519()); + $this->assertFalse($signature->isSr25519()); + } + + public function testSignatureEcdsa(): void + { + $signature = new Signature( + signer: '0x' . str_repeat('01', 32), + signature: '0x' . str_repeat('cc', 65), + signerType: 'ecdsa' + ); + + $this->assertTrue($signature->isEcdsa()); + $this->assertFalse($signature->isSr25519()); + } + + public function testSignatureGetters(): void + { + $signature = new Signature( + signer: '0x' . str_repeat('01', 32), + signature: '0x' . str_repeat('aa', 64), + extra: [ + 'era' => ['type' => 'mortal', 'phase' => 100], + 'nonce' => 42, + 'tip' => 1000 + ] + ); + + $this->assertEquals(['type' => 'mortal', 'phase' => 100], $signature->getEra()); + $this->assertEquals(42, $signature->getNonce()); + $this->assertEquals(1000, $signature->getTip()); + } + + // ==================== Extrinsic Tests ==================== + + public function testExtrinsicUnsigned(): void + { + $extrinsic = new Extrinsic( + call: ['pallet' => 'System', 'function' => 'remark', 'args' => []], + signature: null + ); + + $this->assertFalse($extrinsic->isSigned()); + $this->assertEquals('System', $extrinsic->getPallet()); + $this->assertEquals('remark', $extrinsic->getFunction()); + $this->assertNull($extrinsic->getSigner()); + } + + public function testExtrinsicSigned(): void + { + $signature = new Signature( + signer: '0x' . str_repeat('01', 32), + signature: '0x' . str_repeat('aa', 64) + ); + + $extrinsic = new Extrinsic( + call: ['pallet' => 'Balances', 'function' => 'transfer', 'args' => []], + signature: $signature + ); + + $this->assertTrue($extrinsic->isSigned()); + $this->assertEquals('Balances', $extrinsic->getPallet()); + $this->assertEquals('transfer', $extrinsic->getFunction()); + $this->assertEquals('0x' . str_repeat('01', 32), $extrinsic->getSigner()); + } + + public function testExtrinsicGetters(): void + { + $signature = new Signature( + signer: '0x' . str_repeat('01', 32), + signature: '0x' . str_repeat('aa', 64), + extra: ['nonce' => 10, 'tip' => 500] + ); + + $extrinsic = new Extrinsic( + call: ['pallet' => 'Test', 'function' => 'test', 'args' => ['a' => 1]], + signature: $signature, + extra: ['era' => ['type' => 'immortal']] + ); + + $this->assertEquals(['a' => 1], $extrinsic->getArguments()); + $this->assertEquals(10, $extrinsic->getNonce()); + $this->assertEquals(500, $extrinsic->getTip()); + $this->assertEquals(['type' => 'immortal'], $extrinsic->getEra()); + } + + // ==================== ExtrinsicBuilder Tests ==================== + + public function testBuilderUnsignedExtrinsic(): void + { + $extrinsic = ExtrinsicBuilder::create() + ->pallet('System') + ->function('remark') + ->palletIndex(0) + ->functionIndex(0) + ->args([0x00]) + ->buildUnsigned(); + + $this->assertFalse($extrinsic->isSigned()); + $this->assertEquals('System', $extrinsic->getPallet()); + $this->assertEquals('remark', $extrinsic->getFunction()); + } + + public function testBuilderSignedExtrinsic(): void + { + $extrinsic = ExtrinsicBuilder::create() + ->pallet('Balances') + ->function('transfer') + ->palletIndex(5) + ->functionIndex(0) + ->signer('0x' . str_repeat('01', 32)) + ->signature('0x' . str_repeat('aa', 64)) + ->nonce(1) + ->tip(0) + ->immortal() + ->build(); + + $this->assertTrue($extrinsic->isSigned()); + $this->assertEquals('Balances', $extrinsic->getPallet()); + $this->assertEquals('transfer', $extrinsic->getFunction()); + $this->assertEquals(1, $extrinsic->getNonce()); + } + + public function testBuilderMortalEra(): void + { + $extrinsic = ExtrinsicBuilder::create() + ->pallet('System') + ->function('remark') + ->signer('0x' . str_repeat('01', 32)) + ->signature('0x' . str_repeat('aa', 64)) + ->mortal(64, 100) + ->build(); + + $this->assertNotNull($extrinsic->getEra()); + $this->assertEquals(64, $extrinsic->getEra()['period']); + $this->assertEquals(100, $extrinsic->getEra()['phase']); + } + + public function testBuilderAddArg(): void + { + $extrinsic = ExtrinsicBuilder::create() + ->pallet('Balances') + ->function('transfer') + ->arg('dest', '0x' . str_repeat('ff', 32)) + ->arg('value', 1000) + ->buildUnsigned(); + + $args = $extrinsic->getArguments(); + $this->assertEquals('0x' . str_repeat('ff', 32), $args['dest']); + $this->assertEquals(1000, $args['value']); + } + + // ==================== Encoder/Decoder Round-Trip Tests ==================== + + public function testEncodeUnsignedExtrinsic(): void + { + $extrinsic = ExtrinsicBuilder::create() + ->pallet('System') + ->function('remark') + ->palletIndex(0) + ->functionIndex(0) + ->buildUnsigned(); + + $encoded = $this->encoder->encode($extrinsic); + + $this->assertNotEmpty($encoded->toHex()); + } + + public function testEncodeSignedExtrinsic(): void + { + $extrinsic = ExtrinsicBuilder::create() + ->pallet('System') + ->function('remark') + ->palletIndex(0) + ->functionIndex(0) + ->signer('0x' . str_repeat('01', 32)) + ->signature('0x' . str_repeat('aa', 64)) + ->nonce(0) + ->tip(0) + ->immortal() + ->build(); + + $encoded = $this->encoder->encode($extrinsic); + + $this->assertNotEmpty($encoded->toHex()); + } + + public function testDecodeExtrinsic(): void + { + // Create a simple encoded extrinsic manually + // Length (1 byte) + version (1 byte) + call + $hex = '0x' // prefix + . '04' // length = 4 + . '04' // version 4, unsigned + . '00' // pallet index 0 + . '00'; // function index 0 + + $extrinsic = $this->decoder->decodeHex($hex); + + $this->assertFalse($extrinsic->isSigned()); + $this->assertEquals(0, $extrinsic->call['palletIndex']); + $this->assertEquals(0, $extrinsic->call['functionIndex']); + } + + public function testDecodeSignedExtrinsic(): void + { + // Minimal signed extrinsic + // This is a simplified test - real extrinsics would have proper signatures + $hex = '0x' + . 'a4' // length (approximate) + . '84' // version 4, signed (0x80 | 4) + . '00' // MultiAddress::Id + . str_repeat('01', 32) // signer (32 bytes) + . str_repeat('aa', 64) // signature (64 bytes) + . '00' // era (immortal) + . '00' // nonce (compact 0) + . '00' // tip (compact 0) + . '00' // pallet index + . '00'; // function index + + $extrinsic = $this->decoder->decodeHex($hex); + + $this->assertTrue($extrinsic->isSigned()); + $this->assertEquals('0x' . str_repeat('01', 32), $extrinsic->getSigner()); + } + + // ==================== Version Tests ==================== + + public function testExtrinsicVersion(): void + { + $builder = ExtrinsicBuilder::create() + ->pallet('System') + ->function('remark') + ->version(4); + + $this->assertInstanceOf(ExtrinsicBuilder::class, $builder); + } +}