Root + Impact
Description
`tokenURI` constructs on-chain JSON metadata, Base64-encodes it, and returns the result. The outer `abi.encodePacked` call prepends `_baseURI()`, which is inherited from OpenZeppelin's `ERC721` and returns an empty string `""` by default because the contract never overrides it.
```solidity
return string(
abi.encodePacked(
_baseURI(), // always returns "" — no override exists
Base64.encode(
bytes(
abi.encodePacked(
'{"name":"', profileName, '", ',
'"description":"A soulbound dating profile NFT.", ',
'"attributes": [{"trait_type": "Age", "value": ',
Strings.toString(profileAge),
'}], ',
'"image":"', imageURI, '"}'
)
)
)
)
);
```
The ERC-721 metadata standard (and EIP-721 as widely implemented) requires `tokenURI` to return one of:
- An HTTPS/IPFS URL pointing to a JSON file, **or**
- A data URI of the form `data:application/json;base64,<encoded>` for fully on-chain metadata.
Because `_baseURI()` returns `""`, the function returns a bare Base64 string such as:
```
eyJuYW1lIjoiQWxpY2UiLCAiZGVzY3JpcHRpb24iOiJBIHNvdWxib3VuZC...
```
No marketplace, wallet, or browser can resolve this string as a URI. The JSON data is present but permanently inaccessible to any off-chain consumer.
```solidity
return string(
abi.encodePacked(
_baseURI(),
Base64.encode(
bytes(
abi.encodePacked(
'{"name":"', profileName, '", ',
'"description":"A soulbound dating profile NFT.", ',
'"attributes": [{"trait_type": "Age", "value": ',
Strings.toString(profileAge),
'}], ',
'"image":"', imageURI, '"}'
)
)
)
)
);
```
Risk
Likelihood:
No marketplace, wallet, or browser can resolve this string as a URI. The JSON data is present but permanently inaccessible to any off-chain consumer.
Impact:
Every NFT minted by this contract will show broken metadata on all platforms. Users who mint a profile will have no name, no age attribute, and no profile image displayed anywhere the token is viewed. Since profile identity is the core purpose of this NFT, this failure invalidates the primary use-case of the contract for every user. The issue affects all past and future mints and cannot be corrected retroactively for already-issued tokens without redeployment.
Proof of Concept
Test: `testPoC_TokenURIMissingDataURIPrefix` in `test/testSoulboundProfileNFT.t.sol`.
```solidity
function testPoC_TokenURIMissingDataURIPrefix() public {
vm.prank(user);
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
uint256 tokenId = soulboundNFT.profileToToken(user);
string memory uri = soulboundNFT.tokenURI(tokenId);
emit log_named_string("tokenURI returned", uri);
bytes memory expectedPrefix = bytes("data:application/json;base64,");
bytes memory actualBytes = bytes(uri);
assertTrue(
actualBytes.length >= expectedPrefix.length,
"URI too short to contain data URI prefix"
);
for (uint256 i = 0; i < expectedPrefix.length; i++) {
assertEq(
actualBytes[i],
expectedPrefix[i],
"tokenURI is missing 'data:application/json;base64,' prefix"
);
}
}
```
```
Logs:
tokenURI returned: eyJuYW1lIjoiQWxpY2UiLCAiZGVzY3JpcHRpb24iOiJBIHNv...
[FAIL: tokenURI is missing 'data:application/json;base64,' prefix:
actual[0] = 0x65 ('e')
expected[0] = 0x64 ('d')
]
```
Decoded actual output: `{"name":"Alice", "description":"A soulbound dating profile NFT.", "attributes": [{"trait_type": "Age", "value": 25}], "image":"ipfs://profileImage"}` — the JSON is correct but the data URI wrapper is absent.
Run with: `forge test --match-test testPoC_TokenURIMissingDataURIPrefix -vvv`
Recommended Mitigation
Remove the `_baseURI()` call and prepend the data URI scheme prefix directly:
```solidity
return string(
abi.encodePacked(
"data:application/json;base64,", // add this prefix
Base64.encode(
bytes(
abi.encodePacked(
'{"name":"', profileName, '", ',
'"description":"A soulbound dating profile NFT.", ',
'"attributes": [{"trait_type": "Age", "value": ',
Strings.toString(profileAge),
'}], ',
'"image":"', imageURI, '"}'
)
)
)
)
);
```