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.
Where the URI comes from
Section titled “Where the URI comes from”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.
// TideHookfunction 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 readsfunction tokenURI(uint256 tokenId, bytes32 seed) external pure returns (string memory);The per-token seed (seedOf)
Section titled “The per-token seed (seedOf)”The seed is derived deterministically on the hook and exposed via seedOf(tokenId):
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.
The decoded URI shape
Section titled “The decoded URI shape”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 URIThe 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:
| Layer | What 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 |
| primary | The 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).
How the seed becomes a wave
Section titled “How the seed becomes a wave”The first 5 bytes of the seed deterministically define the Wave:
struct Wave { uint16 hue; uint16 amp; uint16 cycles; uint16 phase; bool harmonic; }| Field | Range | Derivation from seed |
|---|---|---|
hue | 0..359 | uint8(seed[0]) * 360 / 256 |
amp | 30..99 | 30 + uint8(seed[1]) % 70 |
cycles | 2..5 | 2 + uint8(seed[2]) % 4 |
phase | 0..359 | uint8(seed[3]) * 360 / 256 |
harmonic | bool | uint8(seed[4]) & 0x01 != 0 |
On-chain traits
Section titled “On-chain traits”The attributes array embedded in the JSON has exactly five traits, decoded from the same Wave:
trait_type | value | Source |
|---|---|---|
Hue | number 0..359 | w.hue |
Amplitude | string "X.XXm" | _formatCenti(amp * 2) |
Period | string "X.Xh" | _formatDeci(240 / cycles) |
Phase | number 0..359 | w.phase |
Harmonics | number 1 or 2 | 2 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.
Rank / rarity tiers (off-chain overlay)
Section titled “Rank / rarity tiers (off-chain overlay)”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:
| idx | rank | holder tenure |
|---|---|---|
| 0 | Paper | 0 – <1 day |
| 1 | Bronze | 1 – <2 days |
| 2 | Silver | 2 – <3 days |
| 3 | Gold | 3 – <4 days |
| 4 | Platinum | 4 – <5 days |
| 5 | Diamond | ≥ 5 days |
How tenure is computed and what each event does:
heldSinceis derived off-chain from the mirror’s ERC-721Transferevents (mint =Transfer(0x0 → owner)); it is the timestamp of the token’s most recent incomingTransfer.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.
Function reference
Section titled “Function reference”| Function | Visibility | Returns |
|---|---|---|
tokenURI(uint256 tokenId, bytes32 seed) | external pure | Full data:application/json;base64,... URI |
renderSVG(bytes32 seed) | external pure | Raw 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).
See also
Section titled “See also”- TideMirror (Tide-LP NFT) — the ERC-721 surface that exposes
tokenURI. - TideHook — supplies the seed and the
nft*view functions. - Hold = LP (DN404) — why one NFT equals one fee share.
- Architecture overview — how the four contracts fit together.