diff --git a/README.md b/README.md index e5058b8..1ace3ec 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Latest Version on Packagist](https://img.shields.io/packagist/v/tightenco/solana-php-sdk.svg?style=flat-square)](https://packagist.org/packages/tightenco/solana-php-sdk) [![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/tighten/solana-php-sdk/run-tests?label=tests)](https://github.com/tighten/solana-php-sdk/actions?query=workflow%3Arun-tests+branch%3Amain) +> :warning: This is an alpha release; functionality may change. Simple PHP SDK for Solana. @@ -55,6 +56,40 @@ $accountInfoBody = $accountInfoResponse->json(); $accountInfoStatusCode = $accountInfoResponse->getStatusCode(); `````` +### Transactions + +Here is working example of sending a transfer instruction to the Solana blockchain: + +```php +$client = new SolanaRpcClient(SolanaRpcClient::DEVNET_ENDPOINT); +$connection = new Connection($client); +$fromPublicKey = KeyPair::fromSecretKey([...]); +$toPublicKey = new PublicKey('J3dxNj7nDRRqRRXuEMynDG57DkZK4jYRuv3Garmb1i99'); +$instruction = SystemProgram::transfer( + $fromPublicKey->getPublicKey(), + $toPublicKey, + 6 +); + +$transaction = new Transaction(null, null, $fromPublicKey->getPublicKey()); +$transaction->add($instruction); + +$txHash = $connection->sendTransaction($transaction, $fromPublicKey); +``` + +Note: This project is in alpha, the code to generate instructions is still being worked on `$instruction = SystemProgram::abc()` + +## Roadmap + +1. Borsh serialize and deserialize. +2. Improved documentation. +3. Build out more of the Connection, SystemProgram, TokenProgram, MetaplexProgram classes. +4. Improve abstractions around working with binary data. +5. Optimizations: + 1. Leverage PHP more. + 2. Better cache `$recentBlockhash` when sending transactions. +6. Suggestions? Open an issue or PR :D + ## Testing ```bash @@ -72,6 +107,7 @@ If you discover any security related issues, please email hello@tighten.co inste ## Credits - [Matt Stauffer](https://github.com/mattstauffer) +- [Zach Vander Velden](https://github.com/exzachlyvv) - [All Contributors](../../contributors) ## License diff --git a/composer.json b/composer.json index bc12db6..a70bdd6 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,21 @@ "email": "matt@tighten.co", "homepage": "https://tighten.co", "role": "Developer" + }, + { + "name": "Zach Vander Velden", + "email": "zachvv11@gmail.com", + "homepage": "https://zachvv.me", + "role": "Developer" } ], "require": { "php": "^7.4 || ~8.0", + "ext-sodium": "*", "guzzlehttp/guzzle": "^7.3", - "illuminate/http": "~8.0" + "illuminate/http": "~8.0", + "illuminate/support": "^8.0", + "stephenhill/base58": "^1.1" }, "require-dev": { "mockery/mockery": "^1.4", diff --git a/src/Account.php b/src/Account.php new file mode 100644 index 0000000..6dbb0fd --- /dev/null +++ b/src/Account.php @@ -0,0 +1,42 @@ +toString(); + + $this->keypair = Keypair::fromSecretKey($secretKeyString); + } else { + $this->keypair = Keypair::generate(); + } + } + + /** + * @return PublicKey + */ + public function getPublicKey(): PublicKey + { + return $this->keypair->getPublicKey(); + } + + /** + * @return Buffer + */ + public function getSecretKey(): Buffer + { + return $this->keypair->getSecretKey(); + } +} diff --git a/src/Connection.php b/src/Connection.php new file mode 100644 index 0000000..e1e2cff --- /dev/null +++ b/src/Connection.php @@ -0,0 +1,93 @@ +client->call('getAccountInfo', [$pubKey, ["encoding" => "jsonParsed"]])['value']; + + if (! $accountResponse) { + throw new AccountNotFoundException("API Error: Account {$pubKey} not found."); + } + + return $accountResponse; + } + + /** + * @param string $pubKey + * @return float + */ + public function getBalance(string $pubKey): float + { + return $this->client->call('getBalance', [$pubKey])['value']; + } + + /** + * @param string $transactionSignature + * @return array + */ + public function getConfirmedTransaction(string $transactionSignature): array + { + return $this->client->call('getConfirmedTransaction', [$transactionSignature]); + } + + /** + * NEW: This method is only available in solana-core v1.7 or newer. Please use getConfirmedTransaction for solana-core v1.6 + * + * @param string $transactionSignature + * @return array + */ + public function getTransaction(string $transactionSignature): array + { + return $this->client->call('getTransaction', [$transactionSignature]); + } + + /** + * @param Commitment|null $commitment + * @return array + * @throws Exceptions\GenericException|Exceptions\MethodNotFoundException|Exceptions\InvalidIdResponseException + */ + public function getRecentBlockhash(?Commitment $commitment = null): array + { + return $this->client->call('getRecentBlockhash', array_filter([$commitment]))['value']; + } + + /** + * @param Transaction $transaction + * @param Keypair $signer + * @param array $params + * @return array|\Illuminate\Http\Client\Response + * @throws Exceptions\GenericException + * @throws Exceptions\InvalidIdResponseException + * @throws Exceptions\MethodNotFoundException + */ + public function sendTransaction(Transaction $transaction, Keypair $signer, $params = []) + { + if (! $transaction->recentBlockhash) { + $transaction->recentBlockhash = $this->getRecentBlockhash()['blockhash']; + } + + $transaction->sign($signer); + + $rawBinaryString = $transaction->serialize(false); + + $hashString = sodium_bin2base64($rawBinaryString, SODIUM_BASE64_VARIANT_ORIGINAL); + + return $this->client->call('sendTransaction', [ + $hashString, + [ + 'encoding' => 'base64', + 'preflightCommitment' => 'confirmed', + ], + ]); + } +} diff --git a/src/Exceptions/BaseSolanaPhpSdkException.php b/src/Exceptions/BaseSolanaPhpSdkException.php new file mode 100644 index 0000000..94b69cf --- /dev/null +++ b/src/Exceptions/BaseSolanaPhpSdkException.php @@ -0,0 +1,10 @@ +publicKey = Buffer::from($publicKey); + $this->secretKey = Buffer::from($secretKey); + } + + /** + * @return Keypair + * @throws SodiumException + */ + public static function generate(): Keypair + { + $keypair = sodium_crypto_sign_keypair(); + + return static::from($keypair); + } + + /** + * @param string $keypair + * @return Keypair + * @throws SodiumException + */ + public static function from(string $keypair): Keypair + { + return new static( + sodium_crypto_sign_publickey($keypair), + sodium_crypto_sign_secretkey($keypair) + ); + } + + /** + * Create a keypair from a raw secret key byte array. + * + * This method should only be used to recreate a keypair from a previously + * generated secret key. Generating keypairs from a random seed should be done + * with the {@link Keypair.fromSeed} method. + * + * @param $secretKey + * @return Keypair + */ + static public function fromSecretKey($secretKey): Keypair + { + $secretKey = Buffer::from($secretKey)->toString(); + + $publicKey = sodium_crypto_sign_publickey_from_secretkey($secretKey); + + return new static( + $publicKey, + $secretKey + ); + } + + /** + * Generate a keypair from a 32 byte seed. + * + * @param string|array $seed + * @return Keypair + * @throws SodiumException + */ + static public function fromSeed($seed): Keypair + { + $seed = Buffer::from($seed)->toString(); + + $keypair = sodium_crypto_sign_seed_keypair($seed); + + return static::from($keypair); + } + + /** + * The public key for this keypair + * + * @return PublicKey + */ + public function getPublicKey(): PublicKey + { + return new PublicKey($this->publicKey); + } + + /** + * The raw secret key for this keypair + * + * @return Buffer + */ + public function getSecretKey(): Buffer + { + return Buffer::from($this->secretKey); + } +} diff --git a/src/Message.php b/src/Message.php new file mode 100644 index 0000000..1ae59f9 --- /dev/null +++ b/src/Message.php @@ -0,0 +1,221 @@ + + */ + public array $accountKeys; + public string $recentBlockhash; + /** + * @var array + */ + public array $instructions; + + /** + * int to PublicKey: https://github.com/solana-labs/solana-web3.js/blob/966d7c653198de193f607cdfe19a161420408df2/src/message.ts + * + * @var array + */ + private array $indexToProgramIds; + + /** + * @param MessageHeader $header + * @param array $accountKeys + * @param string $recentBlockhash + * @param array $instructions + */ + public function __construct( + MessageHeader $header, + array $accountKeys, + string $recentBlockhash, + array $instructions + ) + { + $this->header = $header; + $this->accountKeys = array_map(function (string $accountKey) { + return new PublicKey($accountKey); + }, $accountKeys); + $this->recentBlockhash = $recentBlockhash; + $this->instructions = $instructions; + + $this->indexToProgramIds = []; + + foreach ($instructions as $instruction) { + $this->indexToProgramIds[$instruction->programIdIndex] = $this->accountKeys[$instruction->programIdIndex]; + } + } + + /** + * @param int $index + * @return bool + */ + public function isAccountSigner(int $index): bool + { + return $index < $this->header->numRequiredSignature; + } + + /** + * @param int $index + * @return bool + */ + public function isAccountWritable(int $index): bool + { + return $index < ($this->header->numRequiredSignature - $this->header->numReadonlySignedAccounts) + || ($index >= $this->header->numRequiredSignature && $index < sizeof($this->accountKeys) - $this->header->numReadonlyUnsignedAccounts); + } + + /** + * @param int $index + * @return bool + */ + public function isProgramId(int $index): bool + { + return array_key_exists($index, $this->indexToProgramIds); + } + + /** + * @return array + */ + public function programIds(): array + { + return array_values($this->indexToProgramIds); + } + + /** + * @return array + */ + public function nonProgramIds(): array + { + return array_filter($this->accountKeys, function (PublicKey $account, $index) { + return ! $this->isProgramId($index); + }); + } + + /** + * @return string + */ + public function serialize(): string + { + $out = new Buffer(); + + $out->push($this->encodeMessage()) + ->push(ShortVec::encodeLength(sizeof($this->instructions))) + ; + + foreach ($this->instructions as $instruction) { + $out->push($this->encodeInstruction($instruction)); + } + + return $out; + } + + /** + * @return array + */ + protected function encodeMessage(): array + { + $publicKeys = []; + + foreach ($this->accountKeys as $publicKey) { + array_push($publicKeys, ...$publicKey->toBytes()); + } + + return [ + // uint8 + ...unpack("C*", pack("C", $this->header->numRequiredSignature)), + // uint8 + ...unpack("C*", pack("C", $this->header->numReadonlySignedAccounts)), + // uint8 + ...unpack("C*", pack("C", $this->header->numReadonlyUnsignedAccounts)), + + ...ShortVec::encodeLength(sizeof($this->accountKeys)), + ...$publicKeys, + ...Buffer::fromBase58($this->recentBlockhash)->toArray(), + ]; + } + + protected function encodeInstruction(CompiledInstruction $instruction): array + { + $data = $instruction->data; + + $accounts = $instruction->accounts;; + + return [ + // uint8 + ...unpack("C*", pack("C", $instruction->programIdIndex)), + + ...ShortVec::encodeLength(sizeof($accounts)), + ...$accounts, + + ...ShortVec::encodeLength(sizeof($data)), + ...$data->toArray(), + ]; + } + + /** + * @param array|Buffer $rawMessage + * @return Message + */ + public static function from($rawMessage): Message + { + $rawMessage = Buffer::from($rawMessage); + + $HEADER_OFFSET = 3; + if (sizeof($rawMessage) < $HEADER_OFFSET) { + throw new InputValidationException('Byte representation of message is missing message header.'); + } + + $numRequiredSignatures = $rawMessage->shift(); + $numReadonlySignedAccounts = $rawMessage->shift(); + $numReadonlyUnsignedAccounts = $rawMessage->shift(); + $header = new MessageHeader($numRequiredSignatures, $numReadonlySignedAccounts, $numReadonlyUnsignedAccounts); + + $accountKeys = []; + list($accountsLength, $accountsOffset) = ShortVec::decodeLength($rawMessage); + for ($i = 0; $i < $accountsLength; $i++) { + $keyBytes = $rawMessage->slice($accountsOffset, PublicKey::LENGTH); + array_push($accountKeys, (new PublicKey($keyBytes))->toBase58()); + $accountsOffset += PublicKey::LENGTH; + } + $rawMessage = $rawMessage->slice($accountsOffset); + + $recentBlockhash = $rawMessage->slice(0, PublicKey::LENGTH)->toBase58String(); + $rawMessage = $rawMessage->slice(PublicKey::LENGTH); + + $instructions = []; + list($instructionCount, $offset) = ShortVec::decodeLength($rawMessage); + $rawMessage = $rawMessage->slice($offset); + for ($i = 0; $i < $instructionCount; $i++) { + $programIdIndex = $rawMessage->shift(); + + list ($accountsLength, $offset) = ShortVec::decodeLength($rawMessage); + $rawMessage = $rawMessage->slice($offset); + $accounts = $rawMessage->slice(0, $accountsLength)->toArray(); + $rawMessage = $rawMessage->slice($accountsLength); + + list ($dataLength, $offset) = ShortVec::decodeLength($rawMessage); + $rawMessage = $rawMessage->slice($offset); + $data = $rawMessage->slice(0, $dataLength); + $rawMessage = $rawMessage->slice($dataLength); + + array_push($instructions, new CompiledInstruction($programIdIndex, $accounts, $data)); + } + + return new Message( + $header, + $accountKeys, + $recentBlockhash, + $instructions + ); + } +} diff --git a/src/Program.php b/src/Program.php new file mode 100644 index 0000000..17324af --- /dev/null +++ b/src/Program.php @@ -0,0 +1,21 @@ +client = $client; + } + + public function __call($method, $params = []): ?array + { + return $this->client->call($method, ...$params); + } +} diff --git a/src/Programs/MetaplexProgram.php b/src/Programs/MetaplexProgram.php new file mode 100644 index 0000000..e427b5c --- /dev/null +++ b/src/Programs/MetaplexProgram.php @@ -0,0 +1,34 @@ +client->call('getProgramAccounts', [ + self::METAPLEX_PROGRAM_ID, + [ + 'encoding' => 'base64', + 'filters' => [ + [ + 'memcmp' => [ + 'bytes' => $pubKey, + 'offset' => $magicOffsetNumber, + ], + ], + ], + ], + ]); + } +} diff --git a/src/Programs/SplTokenProgram.php b/src/Programs/SplTokenProgram.php new file mode 100644 index 0000000..e4adf30 --- /dev/null +++ b/src/Programs/SplTokenProgram.php @@ -0,0 +1,27 @@ +client->call('getTokenAccountsByOwner', [ + $pubKey, + [ + 'programId' => self::SOLANA_TOKEN_PROGRAM_ID, + ], + [ + 'encoding' => 'jsonParsed', + ], + ]); + } +} diff --git a/src/Programs/SystemProgram.php b/src/Programs/SystemProgram.php new file mode 100644 index 0000000..bc25fdd --- /dev/null +++ b/src/Programs/SystemProgram.php @@ -0,0 +1,134 @@ +client->call('getAccountInfo', [$pubKey, ["encoding" => "jsonParsed"]])['value']; + + if (! $accountResponse) { + throw new AccountNotFoundException("API Error: Account {$pubKey} not found."); + } + + return $accountResponse; + } + + /** + * @param string $pubKey + * @return float + */ + public function getBalance(string $pubKey): float + { + return $this->client->call('getBalance', [$pubKey])['value']; + } + + /** + * @param string $transactionSignature + * @return array + */ + public function getConfirmedTransaction(string $transactionSignature): array + { + return $this->client->call('getConfirmedTransaction', [$transactionSignature]); + } + + /** + * NEW: This method is only available in solana-core v1.7 or newer. Please use getConfirmedTransaction for solana-core v1.6 + * + * @param string $transactionSignature + * @return array + */ + public function getTransaction(string $transactionSignature): array + { + return $this->client->call('getTransaction', [$transactionSignature]); + } + + /** + * Generate a transaction instruction that transfers lamports from one account to another + * + * @param PublicKey $fromPubkey + * @param PublicKey $toPublicKey + * @param int $lamports + * @return TransactionInstruction + */ + static public function transfer( + PublicKey $fromPubkey, + PublicKey $toPublicKey, + int $lamports + ): TransactionInstruction + { + // 4 byte instruction index + 8 bytes lamports + // look at https://www.php.net/manual/en/function.pack.php for formats. + $data = [ + // uint32 + ...unpack("C*", pack("V", self::PROGRAM_INDEX_TRANSFER)), + // int64 + ...unpack("C*", pack("P", $lamports)), + ]; + $keys = [ + new AccountMeta($fromPubkey, true, true), + new AccountMeta($toPublicKey, false, true), + ]; + + return new TransactionInstruction( + static::programId(), + $keys, + $data + ); + } + + static public function createAccount( + PublicKey $fromPubkey, + PublicKey $newAccountPublicKey, + int $lamports, + int $space, + PublicKey $programId + ): TransactionInstruction + { + // look at https://www.php.net/manual/en/function.pack.php for formats. + $data = [ + // uint32 + ...unpack("C*", pack("V", self::PROGRAM_INDEX_CREATE_ACCOUNT)), + // int64 + ...unpack("C*", pack("P", $lamports)), + // int64 + ...unpack("C*", pack("P", $space)), + // + ...$programId->toBytes(), + ]; + $keys = [ + new AccountMeta($fromPubkey, true, true), + new AccountMeta($newAccountPublicKey, true, true), + ]; + + return new TransactionInstruction( + static::programId(), + $keys, + $data + ); + } +} diff --git a/src/PublicKey.php b/src/PublicKey.php new file mode 100644 index 0000000..9cfe553 --- /dev/null +++ b/src/PublicKey.php @@ -0,0 +1,215 @@ +buffer = Buffer::from()->pad(self::LENGTH, $bn); + } elseif (is_string($bn)) { + // https://stackoverflow.com/questions/25343508/detect-if-string-is-binary + $isBinaryString = preg_match('~[^\x20-\x7E\t\r\n]~', $bn) > 0; + + // if not binary string already, assumed to be a base58 string. + if ($isBinaryString) { + $this->buffer = Buffer::from($bn); + } else { + $this->buffer = Buffer::fromBase58($bn); + } + + } else { + $this->buffer = Buffer::from($bn); + } + + if (sizeof($this->buffer) !== self::LENGTH) { + $len = sizeof($this->buffer); + throw new InputValidationException("Invalid public key input. Expected length 32. Found: {$len}"); + } + } + + /** + * @return PublicKey + */ + public static function default(): PublicKey + { + return new static('11111111111111111111111111111111'); + } + + /** + * Check if two publicKeys are equal + */ + public function equals($publicKey): bool + { + return $publicKey instanceof PublicKey && $publicKey->buffer === $this->buffer; + } + + /** + * Return the base-58 representation of the public key + */ + public function toBase58(): string + { + return $this->base58()->encode($this->buffer->toString()); + } + + /** + * Return the byte array representation of the public key + */ + public function toBytes(): array + { + return $this->buffer->toArray(); + } + + /** + * Return the Buffer representation of the public key + */ + public function toBuffer(): Buffer + { + return $this->buffer; + } + + /** + * @return string + */ + public function toBinaryString(): string + { + return $this->buffer; + } + + /** + * Return the base-58 representation of the public key + */ + public function __toString() + { + return $this->toBase58(); + } + + /** + * Derive a public key from another key, a seed, and a program ID. + * The program ID will also serve as the owner of the public key, giving + * it permission to write data to the account. + * + * @param PublicKey $fromPublicKey + * @param string $seed + * @param PublicKey $programId + * @return PublicKey + */ + public static function createWithSeed(PublicKey $fromPublicKey, string $seed, PublicKey $programId): PublicKey + { + $buffer = new Buffer(); + + $buffer->push($fromPublicKey) + ->push($seed) + ->push($programId) + ; + + $hash = hash('sha256', $buffer); + $binaryString = sodium_hex2bin($hash); + return new PublicKey($binaryString); + } + + /** + * Derive a program address from seeds and a program ID. + * + * @param array $seeds + * @param PublicKey $programId + * @return PublicKey + */ + public static function createProgramAddress(array $seeds, PublicKey $programId): PublicKey + { + $buffer = new Buffer(); + foreach ($seeds as $seed) { + $seed = Buffer::from($seed); + if (sizeof($seed) > self::MAX_SEED_LENGTH) { + throw new InputValidationException("Max seed length exceeded."); + } + $buffer->push($seed); + } + + $buffer->push($programId)->push('ProgramDerivedAddress'); + + $hash = hash('sha256', $buffer); + $binaryString = sodium_hex2bin($hash); + + if (static::isOnCurve($binaryString)) { + throw new InputValidationException('Invalid seeds, address must fall off the curve.'); + } + + return new PublicKey($binaryString); + } + + /** + * @param array $seeds + * @param PublicKey $programId + * @return array 2 elements, [0] = PublicKey, [1] = integer + */ + static function findProgramAddress(array $seeds, PublicKey $programId): array + { + $nonce = 255; + + while ($nonce != 0) { + try { + $copyOfSeedsWithNonce = $seeds; + array_push($copyOfSeedsWithNonce, [$nonce]); + $address = static::createProgramAddress($copyOfSeedsWithNonce, $programId); + } catch (\Exception $exception) { + $nonce--; + continue; + } + return [$address, $nonce]; + } + + throw new BaseSolanaPhpSdkException('Unable to find a viable program address nonce.'); + } + + /** + * Check that a pubkey is on the ed25519 curve. + */ + static function isOnCurve($publicKey): bool + { + try { + $binaryString = $publicKey instanceof PublicKey + ? $publicKey->toBinaryString() + : $publicKey; + + $_ = sodium_crypto_sign_ed25519_pk_to_curve25519($binaryString); + return true; + } catch (SodiumException $exception) { + return false; + } + } + + /** + * Convenience. + * + * @return Base58 + */ + public static function base58(): Base58 + { + return new Base58(); + } + + public function getPublicKey(): PublicKey + { + return $this; + } +} diff --git a/src/Solana.php b/src/Solana.php deleted file mode 100644 index 95f0212..0000000 --- a/src/Solana.php +++ /dev/null @@ -1,88 +0,0 @@ -client = $client; - } - - public function getAccountInfo(string $pubKey): array - { - $accountResponse = $this->client->call('getAccountInfo', [$pubKey, ["encoding" => "jsonParsed"]])->json()['result']['value']; - - if (! $accountResponse) { - throw new AccountNotFoundException("API Error: Account {$pubKey} not found."); - } - - return $accountResponse; - } - - public function getBalance(string $pubKey): float - { - return $this->client->call('getBalance', [$pubKey])['result']['value']; - } - - public function getConfirmedTransaction(string $transactionSignature): array - { - return $this->client->call('getConfirmedTransaction', [$transactionSignature])['result']; - } - - public function getProgramAccounts(string $pubKey) - { - $magicOffsetNumber = 326; // 🤷‍♂️ - - return $this->client->call('getProgramAccounts', [ - self::metaplexPublicKey, - [ - 'encoding' => 'base64', - 'filters' => [ - [ - 'memcmp' => [ - 'bytes' => $pubKey, - 'offset' => $magicOffsetNumber, - ], - ], - ], - ], - ])->json(); - } - - public function getTokenAccountsByOwner(string $pubKey) - { - return $this->client->call('getTokenAccountsByOwner', [ - $pubKey, - [ - 'programId' => self::solanaTokenProgramId, - ], - [ - 'encoding' => 'jsonParsed', - ], - ])['result']['value']; - } - - // NEW: This method is only available in solana-core v1.7 or newer. Please use getConfirmedTransaction for solana-core v1.6 - public function getTransaction(string $transactionSignature): array - { - return $this->client->call('getTransaction', [$transactionSignature])['result']; - } - - public function getConfirmedSignaturesForAddress2(string $address, int $limit = 5): array - { - return $this->client->call('getConfirmedSignaturesForAddress2', [$address, ['limit' => $limit]])['result']; - } - - public function __call($method, array $params = []): ?array - { - return $this->client->call($method, $params)->json(); - } -} diff --git a/src/SolanaRpcClient.php b/src/SolanaRpcClient.php index 6627595..1f00935 100644 --- a/src/SolanaRpcClient.php +++ b/src/SolanaRpcClient.php @@ -18,27 +18,61 @@ class SolanaRpcClient public const TESTNET_ENDPOINT = 'https://api.testnet.solana.com'; public const MAINNET_ENDPOINT = 'https://api.mainnet-beta.solana.com'; + /** + * Per: https://www.jsonrpc.org/specification + */ + // Invalid JSON was received by the server. + // An error occurred on the server while parsing the JSON text. + public const ERROR_CODE_PARSE_ERROR = -32700; + // The JSON sent is not a valid Request object. + public const ERROR_CODE_INVALID_REQUEST = -32600; + // The method does not exist / is not available. + public const ERROR_CODE_METHOD_NOT_FOUND = -32601; + // Invalid method parameter(s). + public const ERROR_CODE_INVALID_PARAMETERS = -32602; + // Internal JSON-RPC error. + public const ERROR_CODE_INTERNAL_ERROR = -32603; + // Reserved for implementation-defined server-errors. + // -32000 to -32099 is server error - no const. + protected $endpoint; protected $randomKey; + /** + * @param string $endpoint + * @throws \Exception + */ public function __construct(string $endpoint) { $this->endpoint = $endpoint; - $this->randomKey = random_int(5, 25000); + $this->randomKey = random_int(0, 99999999); } - public function call(string $method, array $params = []): Response + /** + * @param string $method + * @param array $params + * @return mixed + * @throws GenericException + * @throws InvalidIdResponseException + * @throws MethodNotFoundException + */ + public function call(string $method, array $params = []) { $response = Http::acceptJson()->post( $this->endpoint, $this->buildRpc($method, $params) - ); + )->throw(); $this->validateResponse($response, $method, $params); - return $response; + return $response->json('result'); } + /** + * @param string $method + * @param array $params + * @return array + */ public function buildRpc(string $method, array $params): array { return [ @@ -49,6 +83,14 @@ public function buildRpc(string $method, array $params): array ]; } + /** + * @param Response $response + * @param string $method + * @param array $params + * @throws GenericException + * @throws InvalidIdResponseException + * @throws MethodNotFoundException + */ protected function validateResponse(Response $response, string $method, array $params): void { if ($response['id'] !== $this->randomKey) { @@ -56,16 +98,12 @@ protected function validateResponse(Response $response, string $method, array $p } if (isset($response['error'])) { - if ($response['error']['code'] === -32601) { + if ($response['error']['code'] === self::ERROR_CODE_METHOD_NOT_FOUND) { throw new MethodNotFoundException("API Error: Method {$method} not found."); } else { throw new GenericException($response['error']['message']); } } - - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { - throw new GenericException('API Error: status code ' . $response->getStatusCode()); - } } /** diff --git a/src/Transaction.php b/src/Transaction.php new file mode 100644 index 0000000..3f30a64 --- /dev/null +++ b/src/Transaction.php @@ -0,0 +1,660 @@ + + */ + public array $signatures; + public ?string $recentBlockhash; + public ?NonceInformation $nonceInformation; + public ?PublicKey $feePayer; + /** + * @var array + */ + public array $instructions = []; + + public function __construct( + ?string $recentBlockhash = null, + ?NonceInformation $nonceInformation = null, + ?PublicKey $feePayer = null, + ?array $signatures = [] + ) + { + $this->recentBlockhash = $recentBlockhash; + $this->nonceInformation = $nonceInformation; + $this->feePayer = $feePayer; + $this->signatures = $signatures; + } + + /** + * The first (payer) Transaction signature + * + * @return string|null + */ + public function signature(): ?string + { + if (sizeof($this->signatures)) { + return $this->signatures[0]->signature; + } + + return null; + } + + /** + * @param ...$items + * @return $this + * @throws GenericException + */ + public function add(...$items): Transaction + { + foreach ($items as $item) { + if ($item instanceof TransactionInstruction) { + array_push($this->instructions, $item); + } elseif ($item instanceof Transaction) { + array_push($this->instructions, ...$item->instructions); + } else { + throw new InputValidationException("Invalid parameter to add(). Only Transaction and TransactionInstruction are allows."); + } + } + + return $this; + } + + /** + * Compile transaction data + * + * @return Message + * @throws GenericException + */ + public function compileMessage(): Message + { + $nonceInfo = $this->nonceInformation; + + if ($nonceInfo && sizeof($this->instructions) && $this->instructions[0] !== $nonceInfo->nonceInstruction) { + $this->recentBlockhash = $nonceInfo->nonce; + array_unshift($this->instructions, $nonceInfo->nonceInstruction); + } + + $recentBlockhash = $this->recentBlockhash; + if (! $recentBlockhash) { + throw new InputValidationException('Transaction recentBlockhash required.'); + } elseif (! sizeof($this->instructions)) { + throw new InputValidationException('No instructions provided.'); + } + + if ($this->feePayer) { + $feePayer = $this->feePayer; + } elseif (sizeof($this->signatures) && $this->signatures[0]->getPublicKey()) { + $feePayer = $this->signatures[0]->getPublicKey(); + } else { + throw new InputValidationException('Transaction fee payer required.'); + } + + + /** + * @var array $programIds + */ + $programIds = []; + /** + * @var array $accountMetas + */ + $accountMetas = []; + + foreach ($this->instructions as $i => $instruction) { + if (! $instruction->programId) { + throw new InputValidationException("Transaction instruction index {$i} has undefined program id."); + } + + array_push($accountMetas, ...$instruction->keys); + + $programId = $instruction->programId->toBase58(); + if (! in_array($programId, $programIds)) { + array_push($programIds, $programId); + } + } + + // Append programID account metas + foreach ($programIds as $programId) { + array_push($accountMetas, new AccountMeta( + new PublicKey($programId), + false, + false + )); + } + + // Sort. Prioritizing first by signer, then by writable + usort($accountMetas, function (AccountMeta $x, AccountMeta $y) { + if ($x->isSigner !== $y->isSigner) { + return $x->isSigner ? -1 : 1; + } + + if ($x->isWritable !== $y->isWritable) { + return $x->isWritable ? -1 : 1; + } + + return 0; + }); + + // Cull duplicate account metas + /** + * @var array $uniqueMetas + */ + $uniqueMetas = []; + foreach ($accountMetas as $accountMeta) { + $eachPublicKey = $accountMeta->getPublicKey(); + $uniqueIndex = $this->arraySearchAccountMetaForPublicKey($uniqueMetas, $eachPublicKey); + + if ($uniqueIndex > -1) { + $uniqueMetas[$uniqueIndex]->isWritable = $uniqueMetas[$uniqueIndex]->isWritable || $accountMeta->isWritable; + } else { + array_push($uniqueMetas, $accountMeta); + } + } + + // Move fee payer to the front + $feePayerIndex = $this->arraySearchAccountMetaForPublicKey($uniqueMetas, $feePayer); + if ($feePayerIndex > -1) { + list($payerMeta) = array_splice($uniqueMetas, $feePayerIndex, 1); + $payerMeta->isSigner = true; + $payerMeta->isWritable = true; + array_unshift($uniqueMetas, $payerMeta); + } else { + array_unshift($uniqueMetas, new AccountMeta($feePayer, true, true)); + } + + // Disallow unknown signers + foreach ($this->signatures as $signature) { + $uniqueIndex = $this->arraySearchAccountMetaForPublicKey($uniqueMetas, $signature); + if ($uniqueIndex > -1) { + $uniqueMetas[$uniqueIndex]->isSigner = true; + } else { + throw new InputValidationException("Unknown signer: {$signature->getPublicKey()->toBase58()}"); + } + } + + $numRequiredSignatures = 0; + $numReadonlySignedAccounts = 0; + $numReadonlyUnsignedAccounts = 0; + + // Split out signing from non-signing keys and count header values + /** + * @var array $signedKeys + */ + $signedKeys = []; + /** + * @var array $unsignedKeys + */ + $unsignedKeys = []; + + foreach ($uniqueMetas as $accountMeta) { + if ($accountMeta->isSigner) { + array_push($signedKeys, $accountMeta->getPublicKey()->toBase58()); + $numRequiredSignatures++; + if (! $accountMeta->isWritable) { + $numReadonlySignedAccounts++; + } + } else { + array_push($unsignedKeys, $accountMeta->getPublicKey()->toBase58()); + if (! $accountMeta->isWritable) { + $numReadonlyUnsignedAccounts++; + } + } + } + + // Initialize signature array, if needed + if (! $this->signatures) { + $this->signatures = array_map(function($signedKey) { + return new SignaturePubkeyPair(new PublicKey($signedKey), null); + }, $signedKeys); + } + + $accountKeys = array_merge($signedKeys, $unsignedKeys); + /** + * @var array $instructions + */ + $instructions = array_map(function (TransactionInstruction $instruction) use ($accountKeys) { + $programIdIndex = array_search($instruction->programId->toBase58(), $accountKeys); + $encodedData = $instruction->data; + $accounts = array_map(function (AccountMeta $meta) use ($accountKeys) { + return array_search($meta->getPublicKey()->toBase58(), $accountKeys); + }, $instruction->keys); + return new CompiledInstruction( + $programIdIndex, + $accounts, + $encodedData + ); + }, $this->instructions); + + return new Message( + new MessageHeader( + $numRequiredSignatures, + $numReadonlySignedAccounts, + $numReadonlyUnsignedAccounts + ), + $accountKeys, + $recentBlockhash, + $instructions + ); + } + + /** + * The Python library takes a little different approach to their implementation of Transaction. It seems simpler to me + * and does not involve the compile method from the JS library. An early implementation of this class used this in a + * 1 to 1 port of the Javascript library, however as I iterated I went away from that. + * + * TODO: Keep this around for a few weeks and delete once we are sure all the kinks with the current implementation + * have been worked out. + * + * @return Message + */ +// protected function compile(): Message +// { +// $message = $this->compileMessage(); +// $signedKeys = array_slice($message->accountKeys, 0, $message->header->numRequiredSignature); +// +// if (sizeof($this->signatures) === sizeof($signedKeys) +// && $this->signatures == $signedKeys) { +// return $message; +// } +// +// $this->signatures = array_map(function (PublicKey $publicKey) { +// return new SignaturePubkeyPair($publicKey, null); +// }, $signedKeys); +// +// return $message; +// } + + /** + * Get a buffer of the Transaction data that need to be covered by signatures + */ + public function serializeMessage(): string + { + return $this->compileMessage()->serialize(); + } + + /** + * Specify the public keys which will be used to sign the Transaction. + * The first signer will be used as the transaction fee payer account. + * + * Signatures can be added with either `partialSign` or `addSignature` + * + * @deprecated Deprecated since v0.84.0. Only the fee payer needs to be + * specified and it can be set in the Transaction constructor or with the + * `feePayer` property. + * + * @param array $signers + */ + public function setSigners(...$signers) + { + $uniqueSigners = $this->arrayUnique($signers); + + $this->signatures = array_map(function(PublicKey $signer) { + return new SignaturePubkeyPair($signer, null); + }, $uniqueSigners); + } + + /** + * Fill in a signature for a partially signed Transaction. + * The `signer` must be the corresponding `Keypair` for a `PublicKey` that was + * previously provided to `signPartial` + * + * @param Keypair $signer + */ + public function addSigner(Keypair $signer) + { + $message = $this->compileMessage(); + $signData = $message->serialize(); + $signature = sodium_crypto_sign_detached($signData, $this->toSecretKey($signer)); + $this->_addSignature($signer->getPublicKey(), $signature); + } + + /** + * Sign the Transaction with the specified signers. Multiple signatures may + * be applied to a Transaction. The first signature is considered "primary" + * and is used identify and confirm transactions. + * + * If the Transaction `feePayer` is not set, the first signer will be used + * as the transaction fee payer account. + * + * Transaction fields should not be modified after the first call to `sign`, + * as doing so may invalidate the signature and cause the Transaction to be + * rejected. + * + * The Transaction must be assigned a valid `recentBlockhash` before invoking this method + * + * @param array $signers + */ + public function sign(...$signers) + { + $this->partialSign(...$signers); + } + + /** + * Partially sign a transaction with the specified accounts. All accounts must + * correspond to either the fee payer or a signer account in the transaction + * instructions. + * + * All the caveats from the `sign` method apply to `partialSign` + * + * @param array $signers + */ + public function partialSign(...$signers) + { + // Dedupe signers + $uniqueSigners = $this->arrayUnique($signers); + + $this->signatures = array_map(function ($signer) { + return new SignaturePubkeyPair($this->toPublicKey($signer), null); + }, $uniqueSigners); + + $message = $this->compileMessage(); + $signData = $message->serialize(); + + foreach ($uniqueSigners as $signer) { + if ($signer instanceof Keypair) { + $signature = sodium_crypto_sign_detached($signData, $this->toSecretKey($signer)); + if (strlen($signature) != self::SIGNATURE_LENGTH) { + throw new InputValidationException('Signature has invalid length.'); + } + $this->_addSignature($this->toPublicKey($signer), $signature); + } + } + } + + /** + * Add an externally created signature to a transaction. The public key + * must correspond to either the fee payer or a signer account in the transaction + * instructions. + * + * @param PublicKey $publicKey + * @param string $signature + * @throws GenericException + */ + public function addSignature(PublicKey $publicKey, string $signature) + { + if (strlen($signature) !== self::SIGNATURE_LENGTH) { + throw new InputValidationException('Signature has invalid length.'); + } + +// $this->compile(); // Ensure signatures array is populated + $this->_addSignature($publicKey, $signature); + } + + /** + * @param PublicKey $publicKey + * @param string $signature + */ + protected function _addSignature(PublicKey $publicKey, string $signature) + { + $indexOfPublicKey = $this->arraySearchAccountMetaForPublicKey($this->signatures, $publicKey); + + if ($indexOfPublicKey === -1) { + throw new InputValidationException("Unknown signer: {$publicKey->toBase58()}"); + } + + $this->signatures[$indexOfPublicKey]->signature = $signature; + } + + /** + * @return bool + */ + public function verifySignatures(): bool + { + return $this->_verifySignature($this->serializeMessage(), true); + } + + /** + * @param string $signData + * @param bool $requireAllSignatures + * @return bool + */ + protected function _verifySignature(string $signData, bool $requireAllSignatures): bool + { + foreach ($this->signatures as $signature) { + if (! $signature->signature) { + if ($requireAllSignatures) { + return false; + } + } else { + if (! sodium_crypto_sign_verify_detached($signature->signature, $signData, $signature->getPublicKey()->toBinaryString())) { + return false; + } + } + } + + return true; + } + + /** + * Serialize the Transaction in the wire format. + * + * @param bool|null $requireAllSignature + * @param bool|null $verifySignatures + */ + public function serialize(bool $requireAllSignature = true, bool $verifySignatures = true) + { + $signData = $this->serializeMessage(); + + if ($verifySignatures && ! $this->_verifySignature($signData, $requireAllSignature)) { + throw new GenericException('Signature verification failed'); + } + + return $this->_serialize($signData); + } + + /** + * @param string $signData + * @return string + */ + protected function _serialize(string $signData): string + { + if (sizeof($this->signatures) >= self::SIGNATURE_LENGTH * 4) { + throw new InputValidationException('Too many signatures to encode.'); + } + + $wireTransaction = new Buffer(); + + $signatureCount = ShortVec::encodeLength(sizeof($this->signatures)); + + // Encode signature count + $wireTransaction->push($signatureCount); + + // Encode signatures + foreach ($this->signatures as $signature) { + if ($signature->signature && strlen($signature->signature) != self::SIGNATURE_LENGTH) { + throw new GenericException("signature has invalid length: {$signature->signature}"); + } + + if ($sig = $signature->signature) { + $wireTransaction->push($sig); + } else { + $wireTransaction->push(array_pad([], self::SIGNATURE_LENGTH, 0)); + } + } + + // Encode signed data + $wireTransaction->push($signData); + + if (sizeof($wireTransaction) > self::PACKET_DATA_SIZE) { + $actualSize = sizeof($wireTransaction); + $maxSize = self::PACKET_DATA_SIZE; + throw new GenericException("transaction too large: {$actualSize} > {$maxSize}"); + } + + return $wireTransaction; + } + + /** + * Parse a wire transaction into a Transaction object. + * + * @param $buffer + * @return Transaction + */ + public static function from($buffer): Transaction + { + $buffer = Buffer::from($buffer); + + list($signatureCount, $offset) = ShortVec::decodeLength($buffer); + $signatures = []; + for ($i = 0; $i < $signatureCount; $i++) { + $signature = $buffer->slice($offset, self::SIGNATURE_LENGTH); + array_push($signatures, $signature->toBase58String()); + $offset += self::SIGNATURE_LENGTH; + } + + $buffer = $buffer->slice($offset); + + return Transaction::populate(Message::from($buffer), $signatures); + } + + /** + * Populate Transaction object from message and signatures + * + * @param Message $message + * @param array $signatures + * @return Transaction + */ + public static function populate(Message $message, array $signatures): Transaction + { + $transaction = new Transaction(); + $transaction->recentBlockhash = $message->recentBlockhash; + + if ($message->header->numRequiredSignature > 0) { + $transaction->feePayer = $message->accountKeys[0]; + } + + foreach ($signatures as $i => $signature) { + array_push($transaction->signatures, new SignaturePubkeyPair( + $message->accountKeys[$i], + $signature === Buffer::from(self::DEFAULT_SIGNATURE)->toBase58String() + ? null + : Buffer::fromBase58($signature)->toString() + )); + } + + foreach ($message->instructions as $instruction) { + $keys = array_map(function (int $accountIndex) use ($transaction, $message) { + $publicKey = $message->accountKeys[$accountIndex]; + $isSigner = static::arraySearchAccountMetaForPublicKey($transaction->signatures, $publicKey) !== -1 + || $message->isAccountSigner($accountIndex); + $isWritable = $message->isAccountWritable($accountIndex); + return new AccountMeta($publicKey, $isSigner, $isWritable); + }, $instruction->accounts); + + array_push($transaction->instructions, new TransactionInstruction( + $message->accountKeys[$instruction->programIdIndex], + $keys, + $instruction->data + )); + } + + return $transaction; + } + + /** + * @param array $haystack + * @param PublicKey|SignaturePubkeyPair|AccountMeta|string $needle + * @return int|string + */ + static protected function arraySearchAccountMetaForPublicKey(array $haystack, $needle) + { + $publicKeyToSearchFor = static::toPublicKey($needle); + + foreach ($haystack as $i => $item) { + if (static::toPublicKey($item) == $publicKeyToSearchFor) { + return $i; + } + } + + return -1; + } + + /** + * @param array $haystack + * @return array + * @throws GenericException + */ + static protected function arrayUnique(array $haystack) + { + $unique = []; + foreach ($haystack as $item) { + $indexOfSigner = static::arraySearchAccountMetaForPublicKey($unique, $item); + + if ($indexOfSigner === -1) { + array_push($unique, $item); + } + } + + return $unique; + } + + /** + * @param $source + * @return PublicKey + * @throws GenericException + */ + static protected function toPublicKey($source): PublicKey + { + if ($source instanceof HasPublicKey) { + return $source->getPublicKey(); + } elseif (is_string($source)) { + return new PublicKey($source); + } else { + throw new InputValidationException('Unsupported input: ' . get_class($source)); + } + } + + /** + * Pulls out the secret key and casts it to a string. + * + * @param $source + * @return string + * @throws InputValidationException + */ + protected function toSecretKey($source): string + { + if ($source instanceof HasSecretKey) { + return $source->getSecretKey(); + } else { + throw new InputValidationException('Unsupported input: ' . get_class($source)); + } + } +} diff --git a/src/TransactionInstruction.php b/src/TransactionInstruction.php new file mode 100644 index 0000000..8655d6f --- /dev/null +++ b/src/TransactionInstruction.php @@ -0,0 +1,23 @@ + + */ + public array $keys; + public PublicKey $programId; + public Buffer $data; + + public function __construct(PublicKey $programId, array $keys, $data = null) + { + $this->programId = $programId; + $this->keys = $keys; + $this->data = Buffer::from($data); + } +} diff --git a/src/Util/AccountMeta.php b/src/Util/AccountMeta.php new file mode 100644 index 0000000..a1946e9 --- /dev/null +++ b/src/Util/AccountMeta.php @@ -0,0 +1,24 @@ +publicKey = $publicKey; + $this->isSigner = $isSigner; + $this->isWritable = $isWritable; + } + + public function getPublicKey(): PublicKey + { + return $this->publicKey; + } +} diff --git a/src/Util/Buffer.php b/src/Util/Buffer.php new file mode 100644 index 0000000..7fc34de --- /dev/null +++ b/src/Util/Buffer.php @@ -0,0 +1,154 @@ + + */ + protected array $data; + + /** + * @param mixed $value + */ + public function __construct($value = null) + { + if (is_string($value)) { + // unpack returns an array indexed at 1. + $this->data = array_values(unpack('C*', $value)); + } elseif (is_array($value)) { + $this->data = $value; + } elseif (is_numeric($value)) { + $this->data = [$value]; + } elseif ($value instanceof PublicKey) { + $this->data = $value->toBytes(); + } elseif ($value instanceof Buffer) { + $this->data = $value->toArray(); + } elseif ($value == null) { + $this->data = []; + } elseif (method_exists($value, 'toArray')) { + $this->data = $value->toArray(); + } else { + throw new InputValidationException('Unsupported $value for Buffer: ' . get_class($value)); + } + } + + /** + * For convenience. + * + * @param $value + * @return Buffer + */ + public static function from($value = null): Buffer + { + return new static($value); + } + + /** + * For convenience. + * + * @param string $value + * @return Buffer + */ + public static function fromBase58(string $value): Buffer + { + $value = PublicKey::base58()->decode($value); + + return new static($value); + } + + /** + * @param $len + * @param int $val + * @return $this + */ + public function pad($len, int $val = 0): Buffer + { + $this->data = array_pad($this->data, $len, $val); + + return $this; + } + + /** + * @param $source + * @return $this + */ + public function push($source): Buffer + { + $sourceAsBuffer = Buffer::from($source); + + array_push($this->data, ...$sourceAsBuffer->toArray()); + + return $this; + } + + /** + * @return Buffer + */ + public function slice(int $offset, ?int $length = null): Buffer + { + return static::from(array_slice($this->data, $offset, $length)); + } + + /** + * @return ?int + */ + public function shift(): ?int + { + return array_shift($this->data); + } + + /** + * Return binary representation of $value. + * + * @return array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * Return binary string representation of $value. + * + * @return string + */ + public function toString(): string + { + return $this; + } + + /** + * Return string representation of $value. + * + * @return string + */ + public function toBase58String(): string + { + return PublicKey::base58()->encode($this->toString()); + } + + /** + * @return int|void + * @throws InputValidationException + */ + public function count() + { + return sizeof($this->toArray()); + } + + /** + * @return string + * @throws InputValidationException + */ + public function __toString() + { + return pack('C*', ...$this->toArray()); + } +} diff --git a/src/Util/Commitment.php b/src/Util/Commitment.php new file mode 100644 index 0000000..8509366 --- /dev/null +++ b/src/Util/Commitment.php @@ -0,0 +1,62 @@ +commitmentLevel = $commitmentLevel; + } + + /** + * @return static + */ + public static function finalized(): Commitment + { + return new static(static::FINALIZED); + } + + /** + * @return static + */ + public static function confirmed(): Commitment + { + return new static(static::CONFIRMED); + } + + /** + * @return static + */ + public static function processed(): Commitment + { + return new static(static::PROCESSED); + } + + /** + * @return string + */ + public function __toString() + { + return $this->commitmentLevel; + } +} diff --git a/src/Util/CompiledInstruction.php b/src/Util/CompiledInstruction.php new file mode 100644 index 0000000..6691797 --- /dev/null +++ b/src/Util/CompiledInstruction.php @@ -0,0 +1,26 @@ + + */ + public array $accounts; + public Buffer $data; + + public function __construct( + int $programIdIndex, + array $accounts, + $data + ) + { + $this->programIdIndex = $programIdIndex; + $this->accounts = $accounts; + $this->data = Buffer::from($data); + } +} diff --git a/src/Util/HasPublicKey.php b/src/Util/HasPublicKey.php new file mode 100644 index 0000000..2d3d5ff --- /dev/null +++ b/src/Util/HasPublicKey.php @@ -0,0 +1,10 @@ +numRequiredSignature = $numRequiredSignature; + $this->numReadonlySignedAccounts = $numReadonlySignedAccounts; + $this->numReadonlyUnsignedAccounts = $numReadonlyUnsignedAccounts; + } +} diff --git a/src/Util/NonceInformation.php b/src/Util/NonceInformation.php new file mode 100644 index 0000000..ed83673 --- /dev/null +++ b/src/Util/NonceInformation.php @@ -0,0 +1,17 @@ +nonce = $nonce; + $this->nonceInstruction = $nonceInstruction; + } +} diff --git a/src/Util/ShortVec.php b/src/Util/ShortVec.php new file mode 100644 index 0000000..4e98b11 --- /dev/null +++ b/src/Util/ShortVec.php @@ -0,0 +1,46 @@ +toArray(); + + $len = 0; + $size = 0; + while ($size < sizeof($buffer)) { + $elem = $buffer[$size]; + $len |= ($elem & 0x7F) << ($size * 7); + $size++; + if (($elem & 0x80) == 0) { + break; + } + } + return [$len, $size]; + } + + public static function encodeLength(int $length): array + { + $elems = []; + $rem_len = $length; + + for (;;) { + $elem = $rem_len & 0x7f; + $rem_len >>= 7; + if (! $rem_len) { + array_push($elems, $elem); + break; + } + $elem |= 0x80; + array_push($elems, $elem); + } + + return $elems; + } +} diff --git a/src/Util/SignaturePubkeyPair.php b/src/Util/SignaturePubkeyPair.php new file mode 100644 index 0000000..393834c --- /dev/null +++ b/src/Util/SignaturePubkeyPair.php @@ -0,0 +1,22 @@ +publicKey = $publicKey; + $this->signature = $signature; + } + + public function getPublicKey(): PublicKey + { + return $this->publicKey; + } +} diff --git a/src/Util/Signer.php b/src/Util/Signer.php new file mode 100644 index 0000000..b142c60 --- /dev/null +++ b/src/Util/Signer.php @@ -0,0 +1,27 @@ +publicKey = $publicKey; + $this->secretKey = $secretKey; + } + + public function getPublicKey(): PublicKey + { + return $this->publicKey; + } + + public function getSecretKey(): Buffer + { + return $this->secretKey; + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/SolanaRpcClientTest.php b/tests/Feature/SolanaRpcClientTest.php similarity index 92% rename from tests/SolanaRpcClientTest.php rename to tests/Feature/SolanaRpcClientTest.php index 02fad1d..d9537ca 100644 --- a/tests/SolanaRpcClientTest.php +++ b/tests/Feature/SolanaRpcClientTest.php @@ -1,9 +1,9 @@ assertEquals(64, sizeof($account->getSecretKey())); + } + + /** @test */ + public function it_account_from_secret_key() + { + $secretKey = [ + 153, 218, 149, 89, 225, 94, 145, 62, 233, 171, 46, 83, 227, 223, 173, 87, + 93, 163, 59, 73, 190, 17, 37, 187, 146, 46, 51, 73, 79, 73, 136, 40, 27, + 47, 73, 9, 110, 62, 93, 189, 15, 207, 169, 192, 192, 205, 146, 217, 171, + 59, 33, 84, 75, 52, 213, 221, 74, 101, 217, 139, 135, 139, 153, 34, + ]; + + $account = new Account($secretKey); + + $this->assertEquals('2q7pyhPwAwZ3QMfZrnAbDhnh9mDUqycszcpf86VgQxhF', $account->getPublicKey()->toBase58()); + } + + /** @test */ + public function it_account_keypair() + { + $expectedAccount = new Account(); + $keypair = Keypair::fromSecretKey($expectedAccount->getSecretKey()); + + $derivedAccount = new Account($keypair->getSecretKey()); + $this->assertEquals($expectedAccount->getPublicKey(), $derivedAccount->getPublicKey()); + $this->assertEquals($expectedAccount->getSecretKey(), $derivedAccount->getSecretKey()); + } +} diff --git a/tests/Unit/KeypairTest.php b/tests/Unit/KeypairTest.php new file mode 100644 index 0000000..07bbcce --- /dev/null +++ b/tests/Unit/KeypairTest.php @@ -0,0 +1,68 @@ +assertEquals(64, sizeof($keypair->getSecretKey())); + $this->assertEquals(32, sizeof($keypair->getPublicKey()->toBytes())); + } + + /** @test */ + public function it_generate_new_keypair() + { + $keypair = Keypair::generate(); + + $this->assertEquals(64, sizeof($keypair->getSecretKey())); + $this->assertEquals(32, sizeof($keypair->getPublicKey()->toBytes())); + } + + /** @test */ + public function it_keypair_from_secret_key() + { + $secretKey = sodium_base642bin('mdqVWeFekT7pqy5T49+tV12jO0m+ESW7ki4zSU9JiCgbL0kJbj5dvQ/PqcDAzZLZqzshVEs01d1KZdmLh4uZIg==', SODIUM_BASE64_VARIANT_ORIGINAL); + + $keypair = Keypair::fromSecretKey($secretKey); + + $this->assertEquals('2q7pyhPwAwZ3QMfZrnAbDhnh9mDUqycszcpf86VgQxhF', $keypair->getPublicKey()->toBase58()); + } + + /** @test */ + public function it_generate_keypair_from_seed() + { + $byteArray = array_fill(0, 32, 8); + + $seedString = pack('C*', ...$byteArray); + + $keypair = Keypair::fromSeed($seedString); + + $this->assertEquals('2KW2XRd9kwqet15Aha2oK3tYvd3nWbTFH1MBiRAv1BE1', $keypair->getPublicKey()->toBase58()); + } + + /** @test */ + public function it_bin2array_and_array2bin_are_equivalent() + { + $keypair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keypair); + + $valueAsArray = Buffer::from($publicKey)->toArray(); + $valueAsString = Buffer::from($valueAsArray)->toString(); + + $this->assertEquals($publicKey, $valueAsString); + } +} diff --git a/tests/Unit/PublicKeyTest.php b/tests/Unit/PublicKeyTest.php new file mode 100644 index 0000000..a42f2b3 --- /dev/null +++ b/tests/Unit/PublicKeyTest.php @@ -0,0 +1,139 @@ +assertEquals([ + 23, 26, 218, 1, 26, 7, 253, 202, 19, 162, 251, 121, 172, 0, 65, 219, 142, 20, 252, 217, 6, 150, 142, 0, 54, 146, 245, 140, 155, 194, 42, 131, + ], $publicKey->toBuffer()->toArray()); + + $this->assertEquals('2ZC8EZduQGavJB9duMUgpdjNj7TQUiMawb52CLXBH5yc', $publicKey->toBase58()); + } + + /** @test */ + public function it_correctly_evaluates_equality() + { + $publicKey1 = new PublicKey('2ZC8EZduQGavJB9duMUgpdjNj7TQUiMawb52CLXBH5yc'); + $publicKey2 = new PublicKey('2ZC8EZduQGavJB9duMUgpdjNj7TQUiMawb52CLXBH5yc'); + + $this->assertEquals($publicKey1, $publicKey2); + } + + /** @test */ + public function it_correctly_handles_public_key_in_constructor() + { + $publicKey1 = new PublicKey('2ZC8EZduQGavJB9duMUgpdjNj7TQUiMawb52CLXBH5yc'); + $publicKey2 = new PublicKey($publicKey1); + + $this->assertEquals($publicKey1, $publicKey2); + } + + /** @test */ + public function it_equals() + { + $arrayKey = new PublicKey([3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,]); + + $base58Key = new PublicKey('CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3'); + + $this->assertEquals($base58Key, $arrayKey); + } + + /** @test */ + public function it_toBase58() + { + $key1 = new PublicKey('CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3'); + $this->assertEquals('CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3', $key1->toBase58()); + $this->assertEquals('CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3', $key1); + + $key2 = new PublicKey('1111111111111111111111111111BukQL'); + $this->assertEquals('1111111111111111111111111111BukQL', $key2->toBase58()); + $this->assertEquals('1111111111111111111111111111BukQL', $key2); + + $key3 = new PublicKey('11111111111111111111111111111111'); + $this->assertEquals('11111111111111111111111111111111', $key3->toBase58()); + + $key4 = new PublicKey([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,]); + $this->assertEquals('11111111111111111111111111111111', $key4->toBase58()); + } + + /** @test */ + public function it_createWithSeed() + { + $defaultPublicKey = new PublicKey('11111111111111111111111111111111'); + $derivedKey = PublicKey::createWithSeed($defaultPublicKey, 'limber chicken: 4/45', $defaultPublicKey); + + $this->assertEquals(new PublicKey('9h1HyLCW5dZnBVap8C5egQ9Z6pHyjsh5MNy83iPqqRuq'), $derivedKey); + } + + /** @test */ + public function it_createProgramAddress() + { + $programId = new PublicKey('BPFLoader1111111111111111111111111111111111'); + $publicKey = new PublicKey('SeedPubey1111111111111111111111111111111111'); + + $programAddress = PublicKey::createProgramAddress([ + '', + [1], + ], $programId); + $this->assertEquals(new PublicKey('3gF2KMe9KiC6FNVBmfg9i267aMPvK37FewCip4eGBFcT'), $programAddress); + + $programAddress = PublicKey::createProgramAddress([ + '☉', + ], $programId); + $this->assertEquals(new PublicKey('7ytmC1nT1xY4RfxCV2ZgyA7UakC93do5ZdyhdF3EtPj7'), $programAddress); + + $programAddress = PublicKey::createProgramAddress([ + 'Talking', + 'Squirrels', + ], $programId); + $this->assertEquals(new PublicKey('HwRVBufQ4haG5XSgpspwKtNd3PC9GM9m1196uJW36vds'), $programAddress); + + $programAddress = PublicKey::createProgramAddress([ + $publicKey->toBytes(), + ], $programId); + $this->assertEquals(new PublicKey('GUs5qLUfsEHkcMB9T38vjr18ypEhRuNWiePW2LoK4E3K'), $programAddress); + } + + /** @test */ + public function it_findProgramAddress() + { + $programId = new PublicKey('BPFLoader1111111111111111111111111111111111'); + + list($programAddress, $nonce) = PublicKey::findProgramAddress( + [''], + $programId + ); + + $this->assertEquals( + PublicKey::createProgramAddress([ + '', + [$nonce], + ], $programId), + $programAddress + ); + } + + /** @test */ + public function it_isOnCurve() + { + $onCurvePublicKey = Keypair::generate()->getPublicKey(); + $this->assertTrue(PublicKey::isOnCurve($onCurvePublicKey)); + + // A program address, yanked from one of the above tests. This is a pretty + // poor test vector since it was created by the same code it is testing. + // Unfortunately, I've been unable to find a golden negative example input + // for curve25519 point decompression :/ + $offCurve = new PublicKey('12rqwuEgBYiGhBrDJStCiqEtzQpTTiZbh7teNVLuYcFA'); + $this->assertFalse(PublicKey::isOnCurve($offCurve)); + } +} diff --git a/tests/Unit/ShortVecTest.php b/tests/Unit/ShortVecTest.php new file mode 100644 index 0000000..ffce343 --- /dev/null +++ b/tests/Unit/ShortVecTest.php @@ -0,0 +1,96 @@ +checkDecodedArray([], 0, 0); + $this->checkDecodedArray([5], 1, 5); + $this->checkDecodedArray([0x7F], 1, 0x7F); + $this->checkDecodedArray([0x80, 0x01], 2, 0x80); + $this->checkDecodedArray([0xFF, 0x01], 2, 0xFF); + $this->checkDecodedArray([0x80, 0x02], 2, 0x100); + $this->checkDecodedArray([0x80, 0x02], 2, 0x100); + $this->checkDecodedArray([0xFF, 0xFF, 0x01], 3, 0x7FFF); + $this->checkDecodedArray([0x80, 0x80, 0x80, 0x01], 4, 0x200000); + } + + /** @test */ + public function it_encodeLength() + { + $array = []; + $prevLength = 0; + + $expected = [0]; + $this->checkEncodedArray($array, 0, $prevLength, $expected); + $prevLength += sizeof($expected); + + $expected = [5]; + $this->checkEncodedArray($array, 5, $prevLength, $expected); + $prevLength += sizeof($expected); + + $expected = [0x7F]; + $this->checkEncodedArray($array, 0x7f, $prevLength, $expected); + $prevLength += sizeof($expected); + + $expected = [0x80, 0x01]; + $this->checkEncodedArray($array, 0x80, $prevLength, $expected); + $prevLength += sizeof($expected); + + $expected = [0xff, 0x01]; + $this->checkEncodedArray($array, 0xff, $prevLength, $expected); + $prevLength += sizeof($expected); + + $expected = [0x80, 0x02]; + $this->checkEncodedArray($array, 0x100, $prevLength, $expected); + $prevLength += sizeof($expected); + + $expected = [0xff, 0xff, 0x01]; + $this->checkEncodedArray($array, 0x7fff, $prevLength, $expected); + $prevLength += sizeof($expected); + + $expected = [0x80, 0x80, 0x80, 0x01]; + $this->checkEncodedArray( + $array, + 0x200000, + $prevLength, + $expected + ); + $prevLength += sizeof($expected); + + $this->assertEquals(16, $prevLength); + $this->assertEquals($prevLength, sizeof($array)); + } + + /** + * @param array $array + * @param int $expectedValue + */ + protected function checkDecodedArray(array $array, int $expectedLength, int $expectedValue) + { + list($value, $length) = ShortVec::decodeLength($array); + $this->assertEquals($expectedValue, $value); + $this->assertEquals($expectedLength, $length); + } + + /** + * @param array $array + * @param int $length + * @param int $prevLength + * @param array $expectedArray + */ + protected function checkEncodedArray(array &$array, int $length, int $prevLength, array $expectedArray) + { + $this->assertEquals(sizeof($array), $prevLength); + $actual = ShortVec::encodeLength($length); + array_push($array, ...$actual); + $this->assertEquals(sizeof($array), $prevLength + sizeof($expectedArray)); + $this->assertEquals($expectedArray, array_slice($array, -sizeof($expectedArray))); + } +} diff --git a/tests/SolanaTest.php b/tests/Unit/SolanaTest.php similarity index 50% rename from tests/SolanaTest.php rename to tests/Unit/SolanaTest.php index 0cca4d2..ee1bdf9 100644 --- a/tests/SolanaTest.php +++ b/tests/Unit/SolanaTest.php @@ -1,31 +1,40 @@ shouldReceive('call') - ->with('abcdefg', []) - ->times(1) - ->andReturn($this->fakeResponse()); + $client = new SolanaRpcClient(SolanaRpcClient::DEVNET_ENDPOINT); + $expectedIdInHttpResponse = $client->getRandomKey(); + $solana = new SystemProgram($client); - $solana = new Solana($client); - $solana->abcdefg(); + Http::fake([ + SolanaRpcClient::DEVNET_ENDPOINT => Http::response([ + 'jsonrpc' => '2.0', + 'result' => [], // not important + 'id' => $expectedIdInHttpResponse, + ]), + ]); - M::close(); + $solana->abcdefg([ + 'param1' => 123, + ]); - $this->assertTrue(true); // Keep PHPUnit from squawking; there must be a better way? + Http::assertSent(function (Request $request) { + return $request->url() == SolanaRpcClient::DEVNET_ENDPOINT && + $request['method'] == 'abcdefg' && + $request['params'] == ['param1' => 123]; + }); } /** @test */ @@ -33,13 +42,13 @@ public function it_will_throw_exception_when_rpc_account_response_is_null() { $client = new SolanaRpcClient(SolanaRpcClient::DEVNET_ENDPOINT); $expectedIdInHttpResponse = $client->getRandomKey(); - $solana = new Solana($client); + $solana = new SystemProgram($client); Http::fake([ SolanaRpcClient::DEVNET_ENDPOINT => Http::response([ 'jsonrpc' => '2.0', 'result' => [ 'context' => [ - 'slot' => 6440 + 'slot' => 6440, ], 'value' => null, // no account data. ], @@ -50,15 +59,4 @@ public function it_will_throw_exception_when_rpc_account_response_is_null() $this->expectException(AccountNotFoundException::class); $solana->getAccountInfo('abc123'); } - - protected function fakeResponse(): Response - { - return new Response(new class - { - public function getBody() - { - return 'i am a body yay'; - } - }); - } } diff --git a/tests/Unit/TransactionTest.php b/tests/Unit/TransactionTest.php new file mode 100644 index 0000000..53bfb34 --- /dev/null +++ b/tests/Unit/TransactionTest.php @@ -0,0 +1,320 @@ +getPublicKey()->toBase58(); + $programId = Keypair::generate()->getPublicKey(); + $transaction = new Transaction($recentBlockhash); + $transaction->add(new TransactionInstruction($programId, [ + new AccountMeta($account3->getPublicKey(), true, false), + new AccountMeta($payer->getPublicKey(), true, true), + new AccountMeta($account2->getPublicKey(), true, true), + ])); + + $transaction->setSigners($payer->getPublicKey(), $account2->getPublicKey(), $account3->getPublicKey()); + + $message = $transaction->compileMessage(); + $this->assertEquals($payer->getPublicKey(), $message->accountKeys[0]); + $this->assertEquals($account2->getPublicKey(), $message->accountKeys[1]); + $this->assertEquals($account3->getPublicKey(), $message->accountKeys[2]); + } + + /** @test */ + public function it_payer_is_first_account_meta() + { + $payer = Keypair::generate(); + $other = Keypair::generate(); + $recentBlockHash = Keypair::generate()->getPublicKey()->toBase58(); + $programId = Keypair::generate()->getPublicKey(); + $transaction = new Transaction($recentBlockHash); + + $transaction->add(new TransactionInstruction( + $programId, + [ + new AccountMeta($other->getPublicKey(), true, true), + new AccountMeta($payer->getPublicKey(), true, true), + ], + )); + + $transaction->sign($payer, $other); + $message = $transaction->compileMessage(); + $this->assertEquals($payer->getPublicKey(), $message->accountKeys[0]); + $this->assertEquals($other->getPublicKey(), $message->accountKeys[1]); + $this->assertEquals(2, $message->header->numRequiredSignature); + $this->assertEquals(0, $message->header->numReadonlySignedAccounts); + $this->assertEquals(1, $message->header->numReadonlyUnsignedAccounts); + } + + /** @test */ + public function it_payer_is_writable() + { + $payer = Keypair::generate(); + $recentBlockhash = Keypair::generate()->getPublicKey()->toBase58(); + $programId = Keypair::generate()->getPublicKey(); + $transaction = new Transaction($recentBlockhash); + $transaction->add(new TransactionInstruction($programId, [ + new AccountMeta($payer->getPublicKey(), true, false) + ])); + + $transaction->sign($payer); + $message = $transaction->compileMessage(); + $this->assertEquals($payer->getPublicKey(), $message->accountKeys[0]); + $this->assertEquals(1, $message->header->numRequiredSignature); + $this->assertEquals(0, $message->header->numReadonlySignedAccounts); + $this->assertEquals(1, $message->header->numReadonlyUnsignedAccounts); + } + + /** @test */ + public function it_partialSign() + { + $account1 = Keypair::generate(); + $account2 = Keypair::generate(); + $recentBlockhash = $account1->getPublicKey()->toBase58(); // Fake recentBlockhash + $transfer = SystemProgram::transfer($account1->getPublicKey(), $account2->getPublicKey(), 123); + + $partialTransaction = new Transaction($recentBlockhash); + $partialTransaction->add($transfer); + $partialTransaction->partialSign($account1, $account2->getPublicKey()); + + $this->assertEquals(Transaction::SIGNATURE_LENGTH, strlen($partialTransaction->signature())); + $this->assertCount(2, $partialTransaction->signatures); + $this->assertNotNull($partialTransaction->signatures[0]->signature); + $this->assertNull($partialTransaction->signatures[1]->signature); + + $partialTransaction->addSigner($account2); + $this->assertNotNull($partialTransaction->signatures[0]->signature); + $this->assertNotNull($partialTransaction->signatures[1]->signature); + + $expected = new Transaction($recentBlockhash); + $expected->add($transfer); + $expected->sign($account1, $account2); + $this->assertEquals($expected, $partialTransaction); + } + + /** @test */ + public function it_dedupe_setSigners() + { + $payer = Keypair::generate(); + $duplicate1 = $payer; + $duplicate2 = $payer; + $recentBlockhash = Keypair::generate()->getPublicKey()->toBase58(); + $programId = Keypair::generate()->getPublicKey(); + + $transaction = new Transaction($recentBlockhash); + $transaction->add(new TransactionInstruction( + $programId, + [ + new AccountMeta($duplicate1->getPublicKey(), true, true), + new AccountMeta($payer->getPublicKey(), false, true), + new AccountMeta($duplicate2->getPublicKey(), true, false), + ] + )); + + $transaction->setSigners( + $payer->getPublicKey(), + $duplicate1->getPublicKey(), + $duplicate2->getPublicKey() + ); + + $this->assertCount(1, $transaction->signatures); + $this->assertEquals($payer->getPublicKey(), $transaction->signatures[0]->getPublicKey()); + + $message = $transaction->compileMessage(); + $this->assertEquals($payer->getPublicKey(), $message->accountKeys[0]); + $this->assertEquals(1, $message->header->numRequiredSignature); + $this->assertEquals(0, $message->header->numReadonlySignedAccounts); + $this->assertEquals(1, $message->header->numReadonlyUnsignedAccounts); + } + + /** @test */ + public function it_dedupe_sign() + { + $payer = Keypair::generate(); + $duplicate1 = $payer; + $duplicate2 = $payer; + $recentBlockhash = Keypair::generate()->getPublicKey()->toBase58(); + $programId = Keypair::generate()->getPublicKey(); + + $transaction = new Transaction($recentBlockhash); + $transaction->add(new TransactionInstruction( + $programId, + [ + new AccountMeta($duplicate1->getPublicKey(), true, true), + new AccountMeta($payer->getPublicKey(), false, true), + new AccountMeta($duplicate2->getPublicKey(), true, false), + ] + )); + + $transaction->sign( + $payer, + $duplicate1, + $duplicate2 + ); + + $this->assertCount(1, $transaction->signatures); + $this->assertEquals($payer->getPublicKey(), $transaction->signatures[0]->getPublicKey()); + + $message = $transaction->compileMessage(); + $this->assertEquals($payer->getPublicKey(), $message->accountKeys[0]); + $this->assertEquals(1, $message->header->numRequiredSignature); + $this->assertEquals(0, $message->header->numReadonlySignedAccounts); + $this->assertEquals(1, $message->header->numReadonlyUnsignedAccounts); + } + + /** @test */ + public function it_transfer_signatures() + { + $account1 = Keypair::generate(); + $account2 = Keypair::generate(); + $recentBlockhash = $account1->getPublicKey()->toBase58(); // Fake recentBlockhash + + $transfer1 = SystemProgram::transfer($account1->getPublicKey(), $account2->getPublicKey(), 123); + $transfer2 = SystemProgram::transfer($account2->getPublicKey(), $account1->getPublicKey(), 123); + + $orgTransaction = new Transaction($recentBlockhash); + $orgTransaction->add($transfer1, $transfer2); + $orgTransaction->sign($account1, $account2); + + $newTransaction = new Transaction($orgTransaction->recentBlockhash, null, null, $orgTransaction->signatures); + $newTransaction->add($transfer1, $transfer2); + + $this->assertEquals($orgTransaction, $newTransaction); + } + + /** @test */ + public function it_use_nonce() + { + $account1 = Keypair::generate(); + $account2 = Keypair::generate(); + $nonceAccount = Keypair::generate(); + $nonce = $account2->getPublicKey()->toBase58(); // Fake Nonce hash + + $this->markTestSkipped('TODO once SystemProgram::nonceAdvance is implemented.'); + } + + /** @test */ + public function it_parse_wire_format_and_serialize() + { + $sender = Keypair::fromSeed([8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]); // Arbitrary known account + $recentBlockhash = 'EETubP5AKHgjPAhzPAFcb8BAY1hMH639CWCFTqi3hq1k'; // Arbitrary known recentBlockhash + $recipient = new PublicKey('J3dxNj7nDRRqRRXuEMynDG57DkZK4jYRuv3Garmb1i99'); // Arbitrary known public key + + $transfer = SystemProgram::transfer($sender->getPublicKey(), $recipient, 49); + $expectedTransaction = new Transaction($recentBlockhash, null, $sender->getPublicKey()); + $expectedTransaction->add($transfer); + $expectedTransaction->sign($sender); + + $wireTransaction = sodium_base642bin('AVuErQHaXv0SG0/PchunfxHKt8wMRfMZzqV0tkC5qO6owYxWU2v871AoWywGoFQr4z+q/7mE8lIufNl/kxj+nQ0BAAEDE5j2LG0aRXxRumpLXz29L2n8qTIWIY3ImX5Ba9F9k8r9Q5/Mtmcn8onFxt47xKj+XdXXd3C8j/FcPu7csUrz/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxJrndgN4IFTxep3s6kO0ROug7bEsbx0xxuDkqEvwUusBAgIAAQwCAAAAMQAAAAAAAAA=', SODIUM_BASE64_VARIANT_ORIGINAL); + $tx = Transaction::from($wireTransaction); + + $this->assertEquals($tx, $expectedTransaction); + $this->assertEquals($wireTransaction, $expectedTransaction->serialize()); + } + + /** @test */ + public function it_populate_transaction() + { + $recentBlockhash = new PublicKey(1); + $message = new Message( + new MessageHeader(2, 0, 3), + [ + new PublicKey(1), + new PublicKey(2), + new PublicKey(3), + new PublicKey(4), + new PublicKey(5), + ], + $recentBlockhash, + [ + new CompiledInstruction(4, [1, 2, 3], Buffer::from(array_pad([], 5, 9))), + ], + ); + + $signatures = [ + Buffer::from(array_pad([], 64, 1))->toBase58String(), + Buffer::from(array_pad([], 64, 2))->toBase58String(), + ]; + + $transaction = Transaction::populate($message, $signatures); + $this->assertCount(1, $transaction->instructions); + $this->assertCount(2, $transaction->signatures); + $this->assertEquals($recentBlockhash, $transaction->recentBlockhash); + } + + /** @test */ + public function it_serialize_unsigned_transaction() + { + $sender = Keypair::fromSeed([8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]); // Arbitrary known account + $recentBlockhash = 'EETubP5AKHgjPAhzPAFcb8BAY1hMH639CWCFTqi3hq1k'; // Arbitrary known recentBlockhash + $recipient = new PublicKey('J3dxNj7nDRRqRRXuEMynDG57DkZK4jYRuv3Garmb1i99'); // Arbitrary known public key + + $transfer = SystemProgram::transfer($sender->getPublicKey(), $recipient, 49); + $expectedTransaction = new Transaction($recentBlockhash, null, $sender->getPublicKey()); + $expectedTransaction->add($transfer); + + $this->assertCount(0, $expectedTransaction->signatures); + $expectedTransaction->feePayer = $sender->getPublicKey(); + + // Serializing without signatures is allowed if sigverify disabled. + $expectedTransaction->serialize(true, false); // no exception + // Serializing the message is allowed when signature array has null signatures + $expectedTransaction->serializeMessage(); // no exception + + $expectedTransaction->feePayer = null; + $expectedTransaction->setSigners($sender->getPublicKey()); + $this->assertCount(1, $expectedTransaction->signatures); + + + // Serializing without signatures is allowed if sigverify disabled. + $expectedTransaction->serialize(true, false); // no exception + // Serializing the message is allowed when signature array has null signatures + $expectedTransaction->serializeMessage(); // no exception + + $expectedSerializationWithNoSignatures = sodium_base642bin('AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDE5j2LG0aRXxRumpLXz29L2n8qTIWIY3ImX5Ba9F9k8r9Q5/Mtmcn8onFxt47xKj+XdXXd3C8j/FcPu7csUrz/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxJrndgN4IFTxep3s6kO0ROug7bEsbx0xxuDkqEvwUusBAgIAAQwCAAAAMQAAAAAAAAA=', SODIUM_BASE64_VARIANT_ORIGINAL); + $this->assertEquals($expectedSerializationWithNoSignatures, $expectedTransaction->serialize(false)); + + // Properly signed transaction succeeds + $expectedTransaction->partialSign($sender); + $this->assertCount(1, $expectedTransaction->signatures); + $expectedSerialization = sodium_base642bin('AVuErQHaXv0SG0/PchunfxHKt8wMRfMZzqV0tkC5qO6owYxWU2v871AoWywGoFQr4z+q/7mE8lIufNl/kxj+nQ0BAAEDE5j2LG0aRXxRumpLXz29L2n8qTIWIY3ImX5Ba9F9k8r9Q5/Mtmcn8onFxt47xKj+XdXXd3C8j/FcPu7csUrz/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxJrndgN4IFTxep3s6kO0ROug7bEsbx0xxuDkqEvwUusBAgIAAQwCAAAAMQAAAAAAAAA=', SODIUM_BASE64_VARIANT_ORIGINAL); + + $this->assertEquals($expectedSerialization, $expectedTransaction->serialize()); + $this->assertCount(1, $expectedTransaction->signatures); + } + + /** @test */ + public function it_externally_signed_stake_delegate() + { +// $authority = Keypair::fromSeed(array_pad([], 32, 1)); +// $stake = new PublicKey(2); +// $recentBlockhash = new PublicKey(3); +// $vote = new PublicKey(4); + + $this->markTestSkipped('TODO once StakeProgram is implemented'); + } +}