added IPFS image settings

This commit is contained in:
viraladmin 2026-07-30 09:55:32 -06:00
parent 7ca99356fb
commit ea83f8eb0a
12 changed files with 788 additions and 2 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/api.env
/vendor/
/storage/security-state.json
/storage/nft-cache/

View File

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

View File

@ -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.

View File

@ -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/"

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 800 800">
<rect width="800" height="800" fill="#0b1820"/>
<rect x="72" y="72" width="656" height="656" fill="#142934" stroke="#47d7e8" stroke-width="8"/>
<path d="M248 526l112-130 83 87 62-69 91 112H248z" fill="#286da8"/>
<circle cx="518" cy="282" r="54" fill="#47d7e8"/>
<text x="400" y="635" fill="#ffffff" font-family="Arial, sans-serif" font-size="44" text-anchor="middle">NFT MEDIA</text>
<text x="400" y="685" fill="#9eb5c1" font-family="Arial, sans-serif" font-size="25" text-anchor="middle">Use the IPFS CID to retrieve this asset</text>
</svg>

After

Width:  |  Height:  |  Size: 643 B

View File

@ -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);

View File

@ -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)),
);
}

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Config;
use RuntimeException;
final class NftMediaConfig
{
private function __construct(
public readonly bool $localCache,
public readonly string $ipfsGateway,
public readonly string $cachePath,
public readonly string $placeholderPath,
public readonly int $timeoutSeconds,
public readonly int $maximumMetadataBytes,
public readonly int $maximumDownloadBytes,
public readonly int $maximumImageBytes,
public readonly int $maximumWidth,
public readonly int $maximumHeight,
public readonly int $maximumSourcePixels,
) {
}
public static function fromEnvironment(
Environment $environment,
string $projectRoot,
): self {
$gateway = rtrim(
$environment->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;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Http;
final class ImageResponse
{
public static function send(
string $path,
string $contentType,
bool $cached,
string $cid,
): never {
if (!is_file($path) || !is_readable($path)) {
throw new HttpException(500, 'The NFT placeholder image is unavailable.');
}
$size = filesize($path);
if ($size === false) {
throw new HttpException(500, 'The NFT image could not be read.');
}
header('Content-Type: ' . $contentType);
header('Content-Length: ' . $size);
header('X-Content-Type-Options: nosniff');
header('X-Contractless-IPFS-CID: ' . $cid);
header('X-Contractless-Image-Available: ' . ($cached ? 'true' : 'false'));
header($cached
? 'Cache-Control: public, max-age=86400, immutable'
: 'Cache-Control: public, max-age=300');
readfile($path);
exit;
}
}

454
src/Nft/NftMediaService.php Normal file
View File

@ -0,0 +1,454 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Nft;
use Contractless\Api\Config\NftMediaConfig;
use Contractless\Api\Http\ImageResponse;
use RuntimeException;
use Throwable;
final class NftMediaService
{
public function __construct(private readonly NftMediaConfig $config)
{
}
/**
* @return array{
* cid: string,
* series: int,
* image_available: bool,
* image_url: string,
* metadata: array<string, mixed>|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<string, mixed>,
* 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<string, mixed>,
* 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<string, mixed>,
* 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',
};
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Contractless\Api\Routes;
use Contractless\Api\Application;
use Contractless\Api\Http\JsonResponse;
use Contractless\Api\Http\Request;
use Contractless\Api\Http\Router;
use Contractless\Api\Nft\NftMediaService;
final class NftMediaRoutes
{
public static function register(
Router $router,
Application $application,
NftMediaService $media,
): void {
$router->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];
}
}

View File

@ -393,7 +393,12 @@ final class RpcReplyDecoder
* txid: string,
* block_height: int,
* transaction_hex: string,
* miner_earnings: int
* miner_earnings: list<array{
* type: string,
* asset: string,
* nft_series: int,
* amount_atomic: string
* }>
* }>
*/
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,