Skip to content

TideArt (on-chain art)

TideArt renders every Tide-LP NFT 100% on-chain. There is no IPFS, no metadata server, and no off-chain dependency in the canonical path: tokenURI is a pure function that returns a fully self-contained data:application/json;base64,... URI, with the SVG embedded inside it as a second base64 data URI. Given the same inputs it always returns the same bytes — the art is a deterministic function of a per-token seed.

This NFT is not decoration bolted onto the token. Each Tide-LP NFT is the unit of fee accounting: one whole TIDE held materializes one NFT, and one NFT is exactly one share of the protocol-owned Uniswap v4 position (1/10000 of supply). The picture you see and the share that earns fees are the same object. See Hold = LP (DN404) for the share mechanics and TideMirror for the ERC-721 surface.

Clients should read the token URI through the standard ERC-721 path on the mirror, which forwards to the hook, which calls TideArt:

TideMirror.tokenURI(id) → TideHook.nftTokenURI(id) → TideArt.tokenURI(id, seed)

The hook supplies the seed; TideArt never reads storage.

// TideHook
function nftTokenURI(uint256 tokenId) external view returns (string memory) {
if (_ownerOf(tokenId) == address(0)) revert InvalidTokenId();
return art.tokenURI(tokenId, _deriveSeed(tokenId));
}
// TideArt — pure, no storage reads
function tokenURI(uint256 tokenId, bytes32 seed) external pure returns (string memory);

The seed is derived deterministically on the hook and exposed via seedOf(tokenId):

TideHook._deriveSeed
function _deriveSeed(uint256 id) internal view returns (bytes32) {
return keccak256(abi.encode(id, address(this)));
}

Including address(this) decorrelates seeds across deployments, so the same token id renders differently on different hook deployments. seedOf(tokenId) and nftTokenURI(tokenId) both revert InvalidTokenId if the token has no owner.

tokenURI returns a base64-encoded JSON object. Decoding the outer data:application/json;base64,... payload yields:

{
"name": "Tide #<tokenId>",
"description": "A facet of the Tide v4 liquidity pool. Each NFT is a 1/10000 share of one Uniswap v4 position, rendered fully on-chain as a unique tidal-harmonic gauge.",
"image": "data:image/svg+xml;base64,<base64-encoded SVG>",
"attributes": [ /* 5 traits, see below */ ]
}

The image field is itself a self-contained data URI — decode its base64 payload to get the raw SVG. To inspect just the SVG without decoding twice, TideArt also exposes:

function renderSVG(bytes32 seed) external pure returns (string memory);

In TypeScript, the dapp’s decodeTokenUri helper splits on the base64 marker and JSON.parses the result (SSR-safe). A minimal decode looks like:

// data:application/json;base64,<payload>
const json = JSON.parse(atob(uri.split("base64,")[1]));
const svg = atob(json.image.split("base64,")[1]); // nested SVG data URI

The visual concept — a tidal-harmonic gauge

Section titled “The visual concept — a tidal-harmonic gauge”

Each token renders a tide-gauge measurement schematic: a composite harmonic wave plotted over a mean-sea-level datum, drawn as if on instrument paper. It is a fixed 400×400 SVG on a deep-navy background (#04070f), assembled in this order:

LayerWhat it draws
_grid()Faint 50px blue chart grid (#8fd3ff, opacity 0.06)
_cornerMarks()Viewport crop marks at the four corners (#cfeaff)
_datum()Dashed mean-sea-level line at y=200, labelled MSL
_gauge()Vertical tide-staff on the left (x=20, y 80→320) with ticks
octave (optional)A faint second wave at half amplitude / double frequency — only if harmonic
primaryThe main wave polyline
_crest()A dot + crosshair marker at the first peak of the primary wave
_titleBlock()Monospace footer: TIDE // <digest> and A=<amp>m T=<period>h

The waves are real sampled polylines: _wave(...) samples x from 20 to 380 in 18px steps (21 points), computing y = 200 − amp·sin(angle)/1000 at each point, where sin is an integer Bhaskara I approximation (_sin, scaled to [−1000, 1000]). The primary stroke is HSL-coloured hsl(<hue>,88%,58%); the faint octave uses lightness 70% and opacity 0.30. The footer digest is "0x" plus the first 3 seed bytes as uppercase hex (e.g. 0xAABBCC).

The first 5 bytes of the seed deterministically define the Wave:

struct Wave { uint16 hue; uint16 amp; uint16 cycles; uint16 phase; bool harmonic; }
FieldRangeDerivation from seed
hue0..359uint8(seed[0]) * 360 / 256
amp30..9930 + uint8(seed[1]) % 70
cycles2..52 + uint8(seed[2]) % 4
phase0..359uint8(seed[3]) * 360 / 256
harmonicbooluint8(seed[4]) & 0x01 != 0

The attributes array embedded in the JSON has exactly five traits, decoded from the same Wave:

trait_typevalueSource
Huenumber 0..359w.hue
Amplitudestring "X.XXm"_formatCenti(amp * 2)
Periodstring "X.Xh"_formatDeci(240 / cycles)
Phasenumber 0..359w.phase
Harmonicsnumber 1 or 22 if harmonic else 1

The footer labels match: amplitude is A = amp/50 metres (formatted from amp * 2 centimetres) and period is 240 / cycles tenths of an hour.

A separate, off-chain system layers dynamic AI-generated art on top of the canonical SVG, where the artwork levels up the longer a single wallet holds a token. This is described in docs/nft-ranks-spec.md (status: approved, 2026-06) and is implemented by art-api/ (backend) plus the dapp — not by the contracts.

There are six ranks, one day of holding each, keyed on the current holder’s tenure:

idxrankholder tenure
0Paper0 – <1 day
1Bronze1 – <2 days
2Silver2 – <3 days
3Gold3 – <4 days
4Platinum4 – <5 days
5Diamond≥ 5 days

How tenure is computed and what each event does:

  • heldSince is derived off-chain from the mirror’s ERC-721 Transfer events (mint = Transfer(0x0 → owner)); it is the timestamp of the token’s most recent incoming Transfer.
  • rank(token) = min(5, floor((now − heldSince) / 1 day)).
  • A wallet→wallet transfer resets heldSince → the rank drops back to Paper and the art regenerates for the new holder.
  • A sell is a burn (DN404 fractional redeem): the token id dies and its art record is deleted.
  • A buy mints a new token id that starts at Paper.
  • Rank is capped at Diamond — no regeneration after day 5.

The off-chain seed is keccak256(tokenId) reduced to an int, so each token keeps a coherent composition across all six ranks while the material escalates: Paper sketch → patinated Bronze → polished Silver → gleaming Gold → crisp Platinum → brilliant-cut Diamond with prismatic refraction. A daily cron (00:00 UTC) recomputes rank per still-held token and regenerates only on rank-up (≤ 6 generations per tenure, then capped), bounding cost.

The backend GET /metadata/:id endpoint adds rank-related traits that are not present on-chain, for example:

[
{ "trait_type": "Rank", "value": "Gold" },
{ "trait_type": "Rank #", "value": 3 },
{ "trait_type": "Held", "value": "3d 5h" }
]

The dapp’s useNftBatch overlays the AI image + rank trait when the art API is reachable, and falls back to the on-chain SVG per token whenever it is not — so a token never shows a broken image.

FunctionVisibilityReturns
tokenURI(uint256 tokenId, bytes32 seed)external pureFull data:application/json;base64,... URI
renderSVG(bytes32 seed)external pureRaw SVG string (off-chain preview)

Both are pure and read no storage. Clients normally never call TideArt directly — they read TideMirror.tokenURI(id) (canonical ERC-721 path) or TideHook.nftTokenURI(id) / TideHook.seedOf(id).