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 @@
+
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,