Evolving NFTs on Algorand

Recently I've been thinking about dynamic NFTs.

Algorand's ARC-19 provides a way to update an NFT's metadata over time, by changing the reference in the reserve address field.

It relies on the token manager submitting a new transaction to transition the asset from one state to another.

But it got me thinking - what if tokens could evolve on their own?

How could we even approach that problem?

A Language for Expressions

For metadata to change over time, we need a common language for expressing transformations.

It should be something that is safe and platform agnostic.

Most NFTs that have dynamic artwork use embedded Javascript and/or HTML, which is dangerous for websites to render.

It's also important for the transformations to be deterministic, because we all need to agree on the state of a token.

You might view it in a mobile game; I might view it in a web browser.

We should both see the same thing at the same time.

As it turns out, there is a wonderful little language called JSONata that has these properties.

It was originally developed by IBM, and is now a flourishing open-source project.

JSONata allows us to write declarative expressions that transform JSON (yes, NFT metadata).

Pure Functions

We can make our transformations deterministic by using pure functions.

Given the same arguments, our functions should always return the same result.

Let's look at an example in Python.

Say we want a function that returns True before lunchtime, and False afterwards.

We could write:

from datetime import datetime

def is_morning() -> bool:
    return datetime.now().hour < 12

But this function is obviously not deterministic.

The result will change depending on when it is executed.

Instead, if we pass the datetime object as an argument:

from datetime import datetime

def is_morning(timestamp: datetime) -> bool:
    return timestamp.hour < 12

Then the result is deterministic.

Given the same timestamp, the function will always return the same result.

This example might seem trivial, but the principle behind it is important.

Instead of changing based on external factors, the result is now purely a function of the timestamp object.

We can calculate whether any time (past, present, or future) is morning.

Algorand block 37044072 was finalised at March 16, 2024, 1:51 a.m (UTC), and we can all agree this event occurred in the morning:

>> from datetime import datetime

>>> def is_morning(timestamp: datetime) -> bool:
...     return timestamp.hour < 12

>>> block_timestamp = datetime(2024, 3, 16, 1, 51, 0)
>>> print(is_morning(block_timestamp))
True

One important property of pure functions is referential transparency - the ability to replace an expression with its result, without changing the behaviour of the program.

For example, we can substitute is_morning(datetime(2024, 1, 1)) for True anywhere in our program, and we'll arrive at the same end state.

This is critical for evolving tokens, because it means that the state of the token expressed in pure functions is equivalent to the resulting value.

A Real World Example

Let's say we issue an event ticket as an NFT.

Before the event, the token acts as an entry pass.

After the event, it becomes a collectible.

In Python, this might look something like:

>>> from datetime import datetime

>>> def token_state(timestamp: datetime) -> str:
...     event_date = datetime(2024, 1, 1)
...     return "entry pass" if timestamp <= event_date else "collectible"

>>> print(token_state(datetime(2023, 12, 31)))
>>> print(token_state(datetime(2024, 1, 2)))
entry pass
collectible

The event date 2024-01-01 is 1704031200000 milliseconds from the Unix Epoch.

In JSONata, we can use the following expression:

$tokenState := function($timestamp) {$timestamp <= 1704031200000 ? "entry pass" : "collectible"};

We can now check the state of the token before the event by providing:

{ 
  "at": 1703944800000
}
(   
    $tokenState := function($timestamp) {$timestamp <= 1704031200000 ? "entry pass" : "collectible"};
    $tokenState(at)
)

Which returns 'entry pass'.

We can also return a transformed JSON object:

(   
    $tokenState := function($timestamp) {$timestamp <= 1704031200000 ? "entry pass" : "collectible"};
    {   
        "at": at,
        "state": $tokenState(at)
    }
)

-->

{
  "at": 1703944800000,
  "state": "entry pass"
}

Which is pretty cool.

Abandoning Our Principles

Purely functional programs are great, but at some point we need to deal with the messy part: IO.

The state of our token should still be deterministic, but for most use cases we are going to want to check its current state.

JSONata allows us to capture the current time, using $now() or $millis().

The time is taken at the start of the expression evaluation and returns the same value for each invocation.

We can think of these closure functions as being equivalent to:

>>> from datetime import datetime
>>> from typing import Callable

>>> def now_closure() -> Callable[[], datetime]:
...     t = datetime.now()
...     def inner() -> datetime:
...         return t
...     return inner

>>> now = now_closure()
>>> print(now())
>>> print(now())
2024-03-16 13:32:33.820066
2024-03-16 13:32:33.820066

It's like taking a snapshot of the environment at the start, and using the same snapshot each time we invoke the function.

If all we care about is the current state, we can simplify the JSONata expression to:

{   
    "state": $millis() <= 1704031200000 ? "entry pass" : "collectible"
}

Which, right now, evaluates to:

{
  "state": "collectible"
}

Transforming Images

With programmable formats like SVG, you could create images that change dynamically over time. Even animations.

But a simpler use case might be where a token has a predefined number of states it can be in, and you create an image to represent each of those states.

Maybe a character can be asleep or awake.

Maybe a world transitions from daytime to nighttime.

Following Algorand's ARC-3 spec, we could define metadata that has a base URL and an expression for determining the current state URL:

{
    "image": "ipfs://a",
    "properties": {
      "created_at": 1678111200,
      "jsonata": {
        "version": "2.0.4",
        "expression": {
          "images": {
            "0": "ipfs://b",
            "1": "ipfs://c"
          },
          "current_image": "$lookup(properties.jsonata.expression.images, $string($floor(($millis() / 1000 - properties.created_at) / 10 % 2)))"
        }
      }
    }
}

The JSONata expression for current_image cycles between ipfs://b and ipfs://c every 10 seconds from the NFT's creation time onwards.

And it's backwards compatible.

If a website doesn't support JSONata, it can still render the base image.

There are so many possible applications for this kind of logic.

Have fun with it!