From ea83f8eb0a7d6de64d0bf9448f7f181afdbcf740 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Thu, 30 Jul 2026 09:55:32 -0600 Subject: [PATCH] added IPFS image settings --- .gitignore | 1 + README.md | 75 +++++ api.env.example | 15 + composer.json | 4 + public/images/nft-placeholder.svg | 8 + public/index.php | 7 + src/Config/ApiConfig.php | 2 + src/Config/NftMediaConfig.php | 99 +++++++ src/Http/ImageResponse.php | 35 +++ src/Nft/NftMediaService.php | 454 ++++++++++++++++++++++++++++++ src/Routes/NftMediaRoutes.php | 63 +++++ src/Rpc/RpcReplyDecoder.php | 27 +- 12 files changed, 788 insertions(+), 2 deletions(-) create mode 100644 public/images/nft-placeholder.svg create mode 100644 src/Config/NftMediaConfig.php create mode 100644 src/Http/ImageResponse.php create mode 100644 src/Nft/NftMediaService.php create mode 100644 src/Routes/NftMediaRoutes.php diff --git a/.gitignore b/.gitignore index d3871a7..09a0581 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /api.env /vendor/ /storage/security-state.json +/storage/nft-cache/ diff --git a/README.md b/README.md index 0101d9e..7e440b5 100644 --- a/README.md +++ b/README.md @@ -443,6 +443,81 @@ original byte count, and transaction type. | `GET` | `/api/v1/tokens/details?name=...` | Details for one token | | `GET` | `/api/v1/nfts` | NFT/RWA list, ownership type, and supply | | `GET` | `/api/v1/nfts/details?name=...&series=...` | Details for one NFT/RWA item | +| `GET` | `/api/v1/nfts/media?name=...&series=...` | NFT/RWA metadata and API image location | +| `GET` | `/api/v1/nfts/image?name=...&series=...` | Cached NFT/RWA image or common placeholder | + +#### NFT And RWA Media + +NFT and RWA transactions store an IPFS CID rather than placing media directly +on the blockchain. API operators choose whether their API retrieves and caches +that media: + +```ini +LOCAL_CACHE=NO +IPFS_GATEWAY=https://gateway.pinata.cloud/ipfs/ +NFT_CACHE_PATH=storage/nft-cache +NFT_PLACEHOLDER_IMAGE=public/images/nft-placeholder.svg +NFT_IPFS_TIMEOUT_SECONDS=10 +NFT_METADATA_MAX_BYTES=1048576 +NFT_DOWNLOAD_MAX_BYTES=25000000 +NFT_IMAGE_MAX_BYTES=5000000 +NFT_IMAGE_MAX_WIDTH=1200 +NFT_IMAGE_MAX_HEIGHT=1200 +NFT_IMAGE_MAX_SOURCE_PIXELS=40000000 +``` + +The default IPFS gateway is the same gateway used by the Contractless GUI +wallet. + +When `LOCAL_CACHE=NO`, the API does not contact the IPFS gateway. It returns +the CID from the NFT transaction, `image_available: false`, and an image URL +that serves the common placeholder. + +When `LOCAL_CACHE=YES`, the API: + +1. Gets the CID from the NFT transaction returned by the configured node. +2. Checks `NFT_CACHE_PATH` using the CID and series number. +3. Retrieves missing metadata and media through `IPFS_GATEWAY`. +4. Returns the placeholder when the gateway times out or the content is + unavailable or invalid. +5. Uses PHP GD to reduce images that exceed the configured dimensions or + cached-image byte limit. +6. Stores the metadata and processed image locally. +7. Serves future requests from the local cache. + +For an NFT series, the API follows the GUI wallet convention and requests +`CID/metadata/SERIES.json`. A one-of-one NFT or RWA uses the transaction CID +as its metadata location. + +Example media response: + +```json +{ + "success": true, + "data": { + "name": "EXAMPLE", + "cid": "bafy...", + "series": 0, + "image_available": true, + "image_url": "/api/v1/nfts/image?name=EXAMPLE&series=0", + "metadata": { + "name": "Example NFT", + "image": "ipfs://bafy.../image.png" + } + } +} +``` + +The CID is always returned, including when the API serves its placeholder. +Applications may therefore retrieve the original media through another IPFS +gateway. + +`NFT_CACHE_PATH` must be writable by the PHP process and should remain outside +the public web directory. Operators may serve the image route through a CDN if +desired. Cached content is not committed to Git. + +The PHP API requires the cURL and GD extensions when local NFT caching is +enabled. `IPFS_GATEWAY` is not used while `LOCAL_CACHE=NO`. ### Loans And Marketing diff --git a/api.env.example b/api.env.example index e613f92..48cd685 100644 --- a/api.env.example +++ b/api.env.example @@ -6,6 +6,21 @@ APP_ENV=production CONTRACTLESS_RPC_NODES=node-1=127.0.0.1:50050,node-2=192.0.2.10:50050 CONTRACTLESS_RPC_TIMEOUT=10 +# NFT and RWA media is only retrieved when LOCAL_CACHE is enabled. The gateway +# default matches the Contractless GUI wallet. When caching is disabled or +# IPFS cannot be reached, the API returns its common placeholder and the CID. +LOCAL_CACHE=NO +IPFS_GATEWAY=https://gateway.pinata.cloud/ipfs/ +NFT_CACHE_PATH=storage/nft-cache +NFT_PLACEHOLDER_IMAGE=public/images/nft-placeholder.svg +NFT_IPFS_TIMEOUT_SECONDS=10 +NFT_METADATA_MAX_BYTES=1048576 +NFT_DOWNLOAD_MAX_BYTES=25000000 +NFT_IMAGE_MAX_BYTES=5000000 +NFT_IMAGE_MAX_WIDTH=1200 +NFT_IMAGE_MAX_HEIGHT=1200 +NFT_IMAGE_MAX_SOURCE_PIXELS=40000000 + # An endpoint enters a temporary cooldown after this many consecutive # connection or transport failures. Protocol and validation failures do not # count because retrying them against another node could conceal bad data. diff --git a/composer.json b/composer.json index 2509f54..c7cfbcd 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,10 @@ "ext-json": "*", "contractless/contractless-php-rpc": "^1.0.0" }, + "suggest": { + "ext-curl": "Required when LOCAL_CACHE=YES to retrieve IPFS metadata and media.", + "ext-gd": "Required when LOCAL_CACHE=YES to validate and resize NFT images." + }, "autoload": { "psr-4": { "Contractless\\Api\\": "src/" diff --git a/public/images/nft-placeholder.svg b/public/images/nft-placeholder.svg new file mode 100644 index 0000000..08e6994 --- /dev/null +++ b/public/images/nft-placeholder.svg @@ -0,0 +1,8 @@ + + + + + + NFT MEDIA + Use the IPFS CID to retrieve this asset + diff --git a/public/index.php b/public/index.php index 978b9f5..a8708ab 100644 --- a/public/index.php +++ b/public/index.php @@ -11,6 +11,8 @@ use Contractless\Api\Http\RequestContext; use Contractless\Api\Http\Router; use Contractless\Api\Rpc\RpcReplyDecoder; use Contractless\Api\Rpc\RequestCredentials; +use Contractless\Api\Nft\NftMediaService; +use Contractless\Api\Routes\NftMediaRoutes; use Contractless\Api\Routes\RemainingRoutes; use Contractless\Api\Security\SecurityMiddleware; use Contractless\Api\Security\TransactionPolicy; @@ -266,6 +268,11 @@ try { }); RemainingRoutes::register($router, $application); + NftMediaRoutes::register( + $router, + $application, + new NftMediaService($config->nftMedia), + ); $router->dispatch($method, $path); } catch (Throwable $error) { ExceptionResponder::respond($error); diff --git a/src/Config/ApiConfig.php b/src/Config/ApiConfig.php index 4320ce0..08a4f97 100644 --- a/src/Config/ApiConfig.php +++ b/src/Config/ApiConfig.php @@ -17,6 +17,7 @@ final class ApiConfig public readonly float $rpcTimeout, public readonly SecurityConfig $security, public readonly ReliabilityConfig $reliability, + public readonly NftMediaConfig $nftMedia, ) { } @@ -28,6 +29,7 @@ final class ApiConfig $environment->float('CONTRACTLESS_RPC_TIMEOUT', 10.0), SecurityConfig::fromEnvironment($environment, dirname(__DIR__, 2)), ReliabilityConfig::fromEnvironment($environment), + NftMediaConfig::fromEnvironment($environment, dirname(__DIR__, 2)), ); } diff --git a/src/Config/NftMediaConfig.php b/src/Config/NftMediaConfig.php new file mode 100644 index 0000000..ad12c68 --- /dev/null +++ b/src/Config/NftMediaConfig.php @@ -0,0 +1,99 @@ +string( + 'IPFS_GATEWAY', + 'https://gateway.pinata.cloud/ipfs/', + ), + '/', + ) . '/'; + if ( + !str_starts_with($gateway, 'https://') + && !str_starts_with($gateway, 'http://') + ) { + throw new RuntimeException( + 'IPFS_GATEWAY must start with http:// or https://.', + ); + } + + $localCache = $environment->bool('LOCAL_CACHE', false); + if ( + $localCache + && (!extension_loaded('curl') || !extension_loaded('gd')) + ) { + throw new RuntimeException( + 'LOCAL_CACHE requires the PHP cURL and GD extensions.', + ); + } + + return new self( + $localCache, + $gateway, + self::path( + $environment->string('NFT_CACHE_PATH', 'storage/nft-cache'), + $projectRoot, + ), + self::path( + $environment->string( + 'NFT_PLACEHOLDER_IMAGE', + 'public/images/nft-placeholder.svg', + ), + $projectRoot, + ), + $environment->int('NFT_IPFS_TIMEOUT_SECONDS', 10, 1, 120), + $environment->int('NFT_METADATA_MAX_BYTES', 1_048_576, 1_024, 10_485_760), + $environment->int('NFT_DOWNLOAD_MAX_BYTES', 25_000_000, 1_024, 100_000_000), + $environment->int('NFT_IMAGE_MAX_BYTES', 5_000_000, 1_024, 25_000_000), + $environment->int('NFT_IMAGE_MAX_WIDTH', 1_200, 32, 10_000), + $environment->int('NFT_IMAGE_MAX_HEIGHT', 1_200, 32, 10_000), + $environment->int('NFT_IMAGE_MAX_SOURCE_PIXELS', 40_000_000, 1_024, 100_000_000), + ); + } + + private static function path(string $path, string $projectRoot): string + { + $path = trim($path); + if ($path === '') { + throw new RuntimeException('NFT cache paths cannot be empty.'); + } + if (!self::isAbsolutePath($path)) { + return $projectRoot . DIRECTORY_SEPARATOR + . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + } + return $path; + } + + private static function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') + || str_starts_with($path, '\\') + || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1; + } +} diff --git a/src/Http/ImageResponse.php b/src/Http/ImageResponse.php new file mode 100644 index 0000000..55c4a77 --- /dev/null +++ b/src/Http/ImageResponse.php @@ -0,0 +1,35 @@ +|null + * } + */ + public function describe(string $name, string $cid, int $series): array + { + $cid = $this->cid($cid); + $available = false; + $metadata = null; + + if ($this->config->localCache) { + try { + $record = $this->cachedOrFetch($cid, $series); + $available = $record !== null; + $metadata = $record['metadata'] ?? null; + } catch (Throwable) { + $available = false; + $metadata = null; + } + } + + return [ + 'cid' => $cid, + 'series' => $series, + 'image_available' => $available, + 'image_url' => '/api/v1/nfts/image?name=' + . rawurlencode($name) + . '&series=' . $series, + 'metadata' => $metadata, + ]; + } + + public function sendImage(string $cid, int $series): never + { + $cid = $this->cid($cid); + if ($this->config->localCache) { + try { + $record = $this->cachedOrFetch($cid, $series); + if ($record !== null) { + ImageResponse::send( + $record['image_path'], + $record['image_mime'], + true, + $cid, + ); + } + } catch (Throwable) { + // The common placeholder is the expected fallback. + } + } + + ImageResponse::send( + $this->config->placeholderPath, + $this->placeholderMime(), + false, + $cid, + ); + } + + /** + * @return array{ + * metadata: array, + * image_path: string, + * image_mime: string + * }|null + */ + private function cachedOrFetch(string $cid, int $series): ?array + { + $directory = $this->cacheDirectory($cid, $series); + $record = $this->readRecord($directory); + if ($record !== null) { + return $record; + } + + $this->ensureDirectory($directory); + $lock = fopen($directory . DIRECTORY_SEPARATOR . 'cache.lock', 'c'); + if ($lock === false) { + throw new RuntimeException('Unable to lock the NFT cache.'); + } + + try { + if (!flock($lock, LOCK_EX)) { + throw new RuntimeException('Unable to lock the NFT cache.'); + } + $record = $this->readRecord($directory); + if ($record !== null) { + return $record; + } + return $this->fetchAndCache($cid, $series, $directory); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + /** + * @return array{ + * metadata: array, + * image_path: string, + * image_mime: string + * } + */ + private function fetchAndCache(string $cid, int $series, string $directory): array + { + $metadataPath = $series > 0 + ? $cid . '/metadata/' . $series . '.json' + : $cid; + $metadataBytes = $this->download( + $metadataPath, + $this->config->maximumMetadataBytes, + ); + try { + $metadata = json_decode( + $metadataBytes, + true, + flags: JSON_THROW_ON_ERROR, + ); + } catch (\JsonException) { + throw new RuntimeException('IPFS NFT metadata is not valid JSON.'); + } + if (!is_array($metadata)) { + throw new RuntimeException('IPFS NFT metadata must be a JSON object.'); + } + + $imageReference = $metadata['image'] + ?? $metadata['image_url'] + ?? $metadata['animation_url'] + ?? null; + if (!is_string($imageReference) || trim($imageReference) === '') { + throw new RuntimeException('IPFS NFT metadata does not contain an image.'); + } + $imagePath = $this->imagePath($imageReference, $metadataPath); + $imageBytes = $this->download( + $imagePath, + $this->config->maximumDownloadBytes, + ); + [$processed, $mime] = $this->processImage($imageBytes); + + $imageFile = $directory . DIRECTORY_SEPARATOR . 'image.bin'; + $recordFile = $directory . DIRECTORY_SEPARATOR . 'record.json'; + $this->atomicWrite($imageFile, $processed); + $this->atomicWrite( + $recordFile, + json_encode( + [ + 'cid' => $cid, + 'series' => $series, + 'metadata' => $metadata, + 'image_mime' => $mime, + ], + JSON_UNESCAPED_SLASHES + | JSON_UNESCAPED_UNICODE + | JSON_THROW_ON_ERROR, + ), + ); + + return [ + 'metadata' => $metadata, + 'image_path' => $imageFile, + 'image_mime' => $mime, + ]; + } + + /** + * @return array{ + * metadata: array, + * image_path: string, + * image_mime: string + * }|null + */ + private function readRecord(string $directory): ?array + { + $recordFile = $directory . DIRECTORY_SEPARATOR . 'record.json'; + $imageFile = $directory . DIRECTORY_SEPARATOR . 'image.bin'; + if (!is_file($recordFile) || !is_file($imageFile)) { + return null; + } + $json = file_get_contents($recordFile); + if ($json === false) { + return null; + } + try { + $record = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + if ( + !is_array($record) + || !is_array($record['metadata'] ?? null) + || !is_string($record['image_mime'] ?? null) + ) { + return null; + } + return [ + 'metadata' => $record['metadata'], + 'image_path' => $imageFile, + 'image_mime' => $record['image_mime'], + ]; + } + + private function download(string $ipfsPath, int $maximumBytes): string + { + $url = $this->config->ipfsGateway . $this->encodedPath($ipfsPath); + $bytes = ''; + $tooLarge = false; + $handle = curl_init($url); + if ($handle === false) { + throw new RuntimeException('Unable to initialize the IPFS request.'); + } + + curl_setopt_array($handle, [ + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_CONNECTTIMEOUT => $this->config->timeoutSeconds, + CURLOPT_TIMEOUT => $this->config->timeoutSeconds, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_USERAGENT => 'Contractless-PHP-API/1.0', + CURLOPT_WRITEFUNCTION => static function ( + mixed $curl, + string $chunk, + ) use (&$bytes, &$tooLarge, $maximumBytes): int { + if (strlen($bytes) + strlen($chunk) > $maximumBytes) { + $tooLarge = true; + return 0; + } + $bytes .= $chunk; + return strlen($chunk); + }, + ]); + + try { + $success = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + if ($tooLarge) { + throw new RuntimeException('The IPFS response exceeded its size limit.'); + } + if ($success === false || $status < 200 || $status >= 300) { + throw new RuntimeException('The IPFS gateway did not return the requested data.'); + } + } finally { + curl_close($handle); + } + return $bytes; + } + + /** @return array{string, string} */ + private function processImage(string $bytes): array + { + $details = getimagesizefromstring($bytes); + if ($details === false || !isset($details['mime'])) { + throw new RuntimeException('The IPFS media is not a supported image.'); + } + $mime = strtolower((string) $details['mime']); + if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) { + throw new RuntimeException('The IPFS media uses an unsupported image type.'); + } + $width = (int) $details[0]; + $height = (int) $details[1]; + if ( + $width < 1 + || $height < 1 + || $width > intdiv($this->config->maximumSourcePixels, $height) + ) { + throw new RuntimeException('The IPFS image dimensions exceed the processing limit.'); + } + if ( + strlen($bytes) <= $this->config->maximumImageBytes + && $width <= $this->config->maximumWidth + && $height <= $this->config->maximumHeight + ) { + return [$bytes, $mime]; + } + + $source = imagecreatefromstring($bytes); + if ($source === false) { + throw new RuntimeException('The IPFS image could not be resized.'); + } + try { + $scale = min( + 1.0, + $this->config->maximumWidth / max(1, $width), + $this->config->maximumHeight / max(1, $height), + ); + if (strlen($bytes) > $this->config->maximumImageBytes) { + $scale = min( + $scale, + sqrt($this->config->maximumImageBytes / strlen($bytes)) * 0.9, + ); + } + + for ($attempt = 0; $attempt < 8; $attempt++) { + $targetWidth = max(1, (int) floor($width * $scale)); + $targetHeight = max(1, (int) floor($height * $scale)); + $target = imagecreatetruecolor($targetWidth, $targetHeight); + if ($target === false) { + throw new RuntimeException('The IPFS image could not be resized.'); + } + if ($mime !== 'image/jpeg') { + imagealphablending($target, false); + imagesavealpha($target, true); + $transparent = imagecolorallocatealpha($target, 0, 0, 0, 127); + imagefill($target, 0, 0, $transparent); + } + imagecopyresampled( + $target, + $source, + 0, + 0, + 0, + 0, + $targetWidth, + $targetHeight, + $width, + $height, + ); + ob_start(); + $outputMime = $this->writeImage($target, $mime); + $output = ob_get_clean(); + imagedestroy($target); + if (is_string($output) && strlen($output) <= $this->config->maximumImageBytes) { + return [$output, $outputMime]; + } + $scale *= 0.75; + } + } finally { + imagedestroy($source); + } + throw new RuntimeException('The IPFS image could not be reduced to the cache limit.'); + } + + private function writeImage(\GdImage $image, string $mime): string + { + if ($mime === 'image/jpeg') { + imagejpeg($image, null, 85); + return 'image/jpeg'; + } + if ($mime === 'image/webp' && function_exists('imagewebp')) { + imagewebp($image, null, 82); + return 'image/webp'; + } + imagepng($image, null, 8); + return 'image/png'; + } + + private function imagePath(string $reference, string $metadataPath): string + { + $path = trim($reference); + foreach (['ipfs://', 'ipfs:/', 'ipfs:', '/ipfs/', 'ipfs/'] as $prefix) { + if (str_starts_with(strtolower($path), $prefix)) { + $path = substr($path, strlen($prefix)); + break; + } + } + $path = ltrim($path, '/'); + if ( + str_starts_with($path, 'http://') + || str_starts_with($path, 'https://') + || str_contains($path, '..') + || str_contains($path, "\0") + ) { + throw new RuntimeException('NFT metadata contains an unsafe image reference.'); + } + if (preg_match('/^[A-Za-z0-9][A-Za-z0-9._~\/-]*$/', $path) !== 1) { + throw new RuntimeException('NFT metadata contains an invalid image reference.'); + } + if (preg_match('/^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]+)(\/|$)/', $path) === 1) { + return $path; + } + $directory = str_contains($metadataPath, '/') + ? dirname($metadataPath) + : ''; + return ltrim(($directory === '.' ? '' : $directory . '/') . $path, '/'); + } + + private function cid(string $cid): string + { + $cid = trim($cid); + if ( + strlen($cid) > 100 + || preg_match('/^(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]+)$/', $cid) !== 1 + ) { + throw new RuntimeException('The NFT transaction contains an invalid IPFS CID.'); + } + return $cid; + } + + private function encodedPath(string $path): string + { + return implode('/', array_map('rawurlencode', explode('/', $path))); + } + + private function cacheDirectory(string $cid, int $series): string + { + return $this->config->cachePath . DIRECTORY_SEPARATOR + . hash('sha256', $cid . ':' . $series); + } + + private function ensureDirectory(string $directory): void + { + if ( + !is_dir($directory) + && !mkdir($directory, 0750, true) + && !is_dir($directory) + ) { + throw new RuntimeException('Unable to create the NFT cache directory.'); + } + } + + private function atomicWrite(string $path, string $bytes): void + { + $temporary = $path . '.tmp-' . bin2hex(random_bytes(6)); + if (file_put_contents($temporary, $bytes, LOCK_EX) === false) { + throw new RuntimeException('Unable to write the NFT cache.'); + } + if (!rename($temporary, $path)) { + @unlink($temporary); + throw new RuntimeException('Unable to finalize the NFT cache.'); + } + } + + private function placeholderMime(): string + { + return match (strtolower(pathinfo($this->config->placeholderPath, PATHINFO_EXTENSION))) { + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + default => 'image/svg+xml', + }; + } +} diff --git a/src/Routes/NftMediaRoutes.php b/src/Routes/NftMediaRoutes.php new file mode 100644 index 0000000..5d92fc1 --- /dev/null +++ b/src/Routes/NftMediaRoutes.php @@ -0,0 +1,63 @@ +get('/api/v1/nfts/media', static function () use ( + $application, + $media, + ): never { + [$name, $series, $cid] = self::nftIdentity($application); + JsonResponse::success( + ['name' => $name] + + $media->describe($name, $cid, $series), + ); + }); + + $router->get('/api/v1/nfts/image', static function () use ( + $application, + $media, + ): never { + [, $series, $cid] = self::nftIdentity($application); + $media->sendImage($cid, $series); + }); + } + + /** @return array{string, int, string} */ + private static function nftIdentity(Application $application): array + { + $name = trim(Request::queryString('name', 15)); + if ( + $name === '' + || strlen($name) > 15 + || preg_match('/^[\x20-\x7E]+$/', $name) !== 1 + ) { + throw new \Contractless\Api\Http\HttpException( + 422, + 'Enter a valid NFT name.', + ); + } + $series = Request::queryInt('series', 0, 0, 4_294_967_295); + $reply = $application->client->nftDetails($name, $series); + \Contractless\Api\Rpc\RpcReplyDecoder::notNodeError($reply); + if (strlen($reply) < 173) { + throw new \RuntimeException('The node returned invalid NFT details.'); + } + $cid = trim(substr($reply, 73, 100), "\0 "); + return [$name, $series, $cid]; + } +} diff --git a/src/Rpc/RpcReplyDecoder.php b/src/Rpc/RpcReplyDecoder.php index c212195..e1ad5f2 100644 --- a/src/Rpc/RpcReplyDecoder.php +++ b/src/Rpc/RpcReplyDecoder.php @@ -393,7 +393,12 @@ final class RpcReplyDecoder * txid: string, * block_height: int, * transaction_hex: string, - * miner_earnings: int + * miner_earnings: list * }> */ public static function addressHistory(string $reply): array @@ -420,7 +425,25 @@ final class RpcReplyDecoder } $transaction = substr($reply, $offset, $transactionLength); $offset += $transactionLength; - $minerEarnings = self::u32($reply, $offset); + $earningCount = self::u32($reply, $offset); + $minerEarnings = []; + for ($earningIndex = 0; $earningIndex < $earningCount; $earningIndex++) { + if (strlen($reply) - $offset < 28) { + throw new RuntimeException('The node returned truncated miner earnings.'); + } + $earningType = ord($reply[$offset]); + $offset++; + $asset = rtrim(substr($reply, $offset, 15)); + $offset += 15; + $nftSeries = self::u32($reply, $offset); + $amount = self::u64($reply, $offset); + $minerEarnings[] = [ + 'type' => $earningType === 0 ? 'fee' : 'tip', + 'asset' => $asset, + 'nft_series' => $nftSeries, + 'amount_atomic' => (string) $amount, + ]; + } $records[] = [ 'txid' => $txid, 'block_height' => $height,