NFT floor price calculator

In this example we present a real time NFT floor price calculator. The code is triggered every time the owner of an NFT collection changes, a feat that can only be achieved using SendBlocks storage triggers.

The function computes the price (in ETH) that the buyer paid, and stores it in the function storage for the next runs. Then, based on the last 140 previous sell events, the code calculates a recommended NFT floor price and emits the result back. The calculation is based on Coinbase NFT floor price algorithm.

Set a Storage Trigger on the NFT collection contract address, with the ownership mapping as the variable. Make sure to analyze the contract first to be able to use variable names instead of slots.

Bored Ape Yacht Club NFT collection on Ethereum Mainnet as an example:

{
    "type": "TRIGGER_TYPE_STORAGE_ACCESS",
    "variable": {
        "variable_name": "_tokenOwners"
    },
    "storage_address": "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d"
}
import { ethers } from "https://cdn.skypack.dev/[email protected]";
import { mad, max, median, min, quantileSeq } from "https://cdn.skypack.dev/[email protected]";

const LOOKBACK = 140;
const NAMESPACE = "nftFloorPrice";
const PCT_TARGET = 0.05;
const PCT_TARGET_MIN = 0.02;
const PCT_TARGET_MAX = 0.1;
const SPEED = 0.5;

class OnChainData {
    contract;
    blockNumber;
    txnHash;
    buyerAddress;
    nftTokenId;
    price;

    constructor(contract, blockNumber, price, buyerAddress = null, nftTokenId = null, txnHash = null) {
        this.contract = contract;
        this.blockNumber = blockNumber;
        this.price = price;
        this.buyerAddress = buyerAddress;
        this.nftTokenId = nftTokenId;
        this.txnHash = txnHash;
    }
}

class NftFloorPriceContext {
    lastSalesPrices = [];
    totalSmaller = 0;
    totalSeen = 0;
}

let nftFloorPriceContext = null;

function computeNewQuantile(q_curr, q_target, q_obs, speed, pct_target_min, pct_target_max) {
    return min(pct_target_max, max(pct_target_min, q_curr + speed * (q_target - q_obs)));
}

function processSellEvent(event) {
    nftFloorPriceContext.totalSeen++;
    const latestPrice = calculateFloorPrice(event);
    updatePriceHistory(event);
    return latestPrice;
}

function updatePriceHistory(event) {
    if (nftFloorPriceContext.lastSalesPrices.length < LOOKBACK) {
        nftFloorPriceContext.lastSalesPrices.push(event.price);
        return;
    }
    nftFloorPriceContext.lastSalesPrices.shift();
    nftFloorPriceContext.lastSalesPrices.push(event.price);
}

function calculateFloorPrice(event) {
    let salesPricesLog = [];
    // in case we don't have any history
    if (nftFloorPriceContext.lastSalesPrices.length == 0) {
        salesPricesLog.push(-42.0);
    }

    for (let i = 0; i < nftFloorPriceContext.lastSalesPrices.length; i++) {
        salesPricesLog.push(Math.log(nftFloorPriceContext.lastSalesPrices[i]));
    }

    // remove outliers
    const arrayMedian = median(salesPricesLog);
    const arrayMad = mad(salesPricesLog);
    const lb = arrayMedian - 3 * arrayMad;
    const ub = arrayMedian + 3 * arrayMad;

    let outLiners = [];
    for (let i = 0; i < salesPricesLog.length; i++) {
        if (lb <= salesPricesLog[i] && salesPricesLog[i] <= ub) {
            outLiners.push(salesPricesLog[i]);
        }
    }

    const quantile = quantileSeq(outLiners, PCT_TARGET);

    if (Math.log(event.price) <= quantile) {
        nftFloorPriceContext.totalSmaller++;
    }

    const quantileObs = nftFloorPriceContext.totalSmaller / nftFloorPriceContext.totalSeen;
    const quantileAdj = computeNewQuantile(PCT_TARGET, PCT_TARGET, quantileObs, SPEED, PCT_TARGET_MIN, PCT_TARGET_MAX);
    const logPriceAdj = quantileSeq(outLiners, quantileAdj);
    return Math.exp(logPriceAdj);
}

async function getPrice(txn, context, data, provider) {
    let price = ethers.utils.formatEther(txn["value"]);
    if (price != 0) {
        return parseFloat(price);
    }

    // Since the price is 0, we assume that wrapped eth was moved.
    const buyerAddress = data["finalValue"];
    const filter = {
        topics: [
            "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
            ethers.utils.hexZeroPad(buyerAddress, 32),
            null,
        ],
        fromBlock: context["blockNumber"],
        toBlock: context["blockNumber"],
    };

    const logsFromBlock = await provider.getLogs(filter);
    const logsForTx = logsFromBlock.filter((log) => log.transactionHash == txn.hash);

    let totalPrice = 0.0;
    logsForTx.forEach((element) => {
        totalPrice += parseFloat(ethers.utils.formatEther(element["data"]));
    });
    return totalPrice;
}

export async function triggerHandler(context, data) {
    const provider = new ethers.providers.JsonRpcProvider("http://chain-provider");
    const txn = await provider.getTransaction(context["txHash"]);

    let nftCtx = await sbcore.storage.Load(NAMESPACE, "nftFloorPriceContext", null);
    if (nftCtx === null) {
        nftCtx = new NftFloorPriceContext();
    }
    nftFloorPriceContext = nftCtx;

    const price = await getPrice(txn, context, data, provider);

    const sellEvent = new OnChainData(
        data["logicAddress"],
        parseInt(context["blockNumber"], 16),
        price,
        data["finalValue"],
        parseInt(data["indexes"][0], 16),
        context["txHash"],
    );

    const floorPrice = processSellEvent(sellEvent);
    await sbcore.storage.Store(NAMESPACE, "nftFloorPriceContext", nftFloorPriceContext);

    return {
        floorPrice: floorPrice,
        sellEvent: sellEvent,
    };
}