Skip to content

Instantly share code, notes, and snippets.

@jollyjoker992
Last active November 13, 2025 10:18
Show Gist options
  • Select an option

  • Save jollyjoker992/b178d6078e38f2fdd8109f54a5c78180 to your computer and use it in GitHub Desktop.

Select an option

Save jollyjoker992/b178d6078e38f2fdd8109f54a5c78180 to your computer and use it in GitHub Desktop.
StarQuest Technical Proposal

StarQuest Technical Proposal

Purpose: This document helps you (the artist/developer) implement the HTML/JS artwork so it can be embedded on Feral File and other DP‑1 compatible displays. It defines how query parameters are used, how clips are resolved from IPFS/FF CDN, and how to call the on‑chain contract function seriesArtworksOfOwner(uint256).


1. General Requirements

1.1 Query Parameters

The artwork must accept the following query parameters:

  • contract – Ethereum contract address of the series (checksummed or lowercase).

  • token_id – Token ID of the currently playing artwork (decimal string).

  • mode – How to load clips:

    • scene → load a single clip for the given token_id.
    • episode → load multiple clips based on seriesArtworksOfOwner.
  • clip_directory_url (optional) – Base URL to load clips from.

1.2 Default clip_directory_url

  • If clip_directory_url is not provided, use the default:

    ipfs://<CID>
    
  • Each clip URL follows this pattern:

    :clip_directory_url/:token_id
    

    Example with IPFS CID bafy... and token_id = 42:

    ipfs://bafy.../42
    

1.3 Contract Function Requirement

The artwork must call this contract function when mode=episode:

function seriesArtworksOfOwner(uint256 tokenId) external view returns (uint256[] memory);
  • Input: tokenId – the currently playing token (token_id query param).
  • Output: an array of token IDs that belong to the same owner and the same series/contract.
  • The artwork uses this array to decide which clips to load for the episode mode.

2. Concept & Behavioral Explanation

2.1 Clips & Directories

  • There are 111 clips in total.
  • They will be uploaded to IPFS under a single directory CID.
  • Feral File will also mirror them to a ** CDN** to speed up playback on Feral File surfaces.

2.2 How the Artwork Is Loaded

The artwork is loaded in a browser with a URL like:

https://example.com/art.html
  ?contract=0x...
  &token_id=123
  &mode=scene
  &clip_directory_url=ipfs://<CID>

Key rules:

  • If clip_directory_url is provided → use that as the base for clips.
  • If clip_directory_url is missing → fall back to the default IPFS directory.

2.3 Modes

mode=scene

  • Load a single clip using the token_id:

    effective_clip_url = resolved_clip_directory_url + '/' + token_id
    
  • No contract calls are required.

mode=episode

  1. Take the contract and token_id from query params.
  2. Call seriesArtworksOfOwner(token_id) on the given contract.
  3. Receive an array of token IDs.
  4. Load clips for each of these token IDs from clip_directory_url.
  5. The artwork is free to decide ordering and timing (e.g., linear, shuffled, interactive).

3. Implementation Recommendations

3.1 IPFS Gateway Resolution

  • Feral File will provide a default ipfs://<CID> that should be reachable at https://ipfs.feralfile.com/ipfs/<CID>.

  • To make the artwork more durable and resilient, it’s recommended to:

    1. Maintain a list of preferred IPFS gateways, for example:

      • https://ipfs.feralfile.com/ipfs/
      • https://ipfs.io/ipfs/
      • https://cloudflare-ipfs.com/ipfs/
    2. When you see a ipfs://<CID>/path URL:

      • Extract the CID and path.
      • Try each gateway in parallel by sending a lightweight HEAD request (or a GET with method: 'HEAD' if CORS blocks true HEAD).
      • Use the first gateway that returns a successful response (status code 2xx).

3.2 Contract Call via Multiple Public RPC Nodes

To reduce the risk of RPC downtime or rate‑limit errors:

  1. Keep a list of public RPC endpoints, such as:

    • https://eth.llamarpc.com
    • https://cloudflare-eth.com
    • https://rpc.ankr.com/eth
    • https://ethereum-rpc.publicnode.com
    • https://rpc.mevblocker.io
    • https://eth.drpc.org
  2. For mode=episode:

    • Try eth_call against all endpoints in parallel.
    • Use the first successful response.

This makes contract calls more reliable without tying the artwork to any specific provider.


4. Contract Function: seriesArtworksOfOwner

4.1 Signature

function seriesArtworksOfOwner(uint256 tokenId) external view returns (uint256[] memory);

4.2 Semantics

  • Given a tokenId:

    • Find the owner of that token.

    • Return all token IDs that:

      • Are owned by the same wallet address.
      • Belong to the same series (same contract).
  • The artwork does not need to know how this is implemented; it only relies on the output array.

4.3 Usage in the Artwork

  1. Read contract and token_id from the URL.

  2. Build an ABI‑encoded call for seriesArtworksOfOwner(token_id).

  3. Call eth_call via JSON‑RPC.

  4. Decode the returned uint256[].

  5. Use those token IDs to form clip URLs:

    clip_url_for_token = resolved_clip_directory_url + '/' + tokenId
    

5. Implementation Example – Pure JS (Browser)

Note: This is a minimal, dependency‑free example meant for clarity. In production you can refactor or replace parts with your favorite web3 library. All logic runs in the browser.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>StarGuest Episode</title>
  <style>
    body { margin: 0; background: #000; color: #fff; font-family: system-ui, sans-serif; }
    #debug { position: fixed; bottom: 0; left: 0; right: 0; max-height: 30vh; overflow: auto; font-size: 12px; background: rgba(0,0,0,0.6); padding: 8px; }
    video { width: 100vw; height: 100vh; object-fit: cover; }
  </style>
</head>
<body>
  <video id="player" autoplay muted playsinline controls></video>
  <pre id="debug"></pre>

  <script>
    // -----------------------------
    // 0. Utility helpers
    // -----------------------------

    function log(msg, data) {
      const debug = document.getElementById('debug');
      const line = typeof data !== 'undefined' ? msg + ' ' + JSON.stringify(data) : msg;
      debug.textContent += line + '\n';
    }

    function getQueryParams() {
      const params = new URLSearchParams(window.location.search);
      return {
        contract: params.get('contract') || '',
        tokenId: params.get('token_id') || '',
        mode: (params.get('mode') || 'scene').toLowerCase(),
        clipDirectoryUrl: params.get('clip_directory_url') || 'ipfs://QmaTR7merWY3APFTm3hHGFJ4JhRkk9J5PP7vA6zaSyMFX1'
      };
    }

    // Small helper: "first successful" promise racer
    async function firstSuccessful(promises) {
      return new Promise((resolve, reject) => {
        if (!promises.length) {
          reject(new Error('No promises provided'));
          return;
        }
        let pending = promises.length;
        const errors = [];
        for (const p of promises) {
          p.then(result => {
            // First one to resolve wins
            resolve(result);
          }).catch(err => {
            errors.push(err);
            pending -= 1;
            if (pending === 0) {
              reject(new Error('All promises failed: ' + errors.map(String).join('; ')));
            }
          });
        }
      });
    }

    // -----------------------------
    // 1. IPFS URL → HTTP URL
    // -----------------------------

    const IPFS_GATEWAYS = [
      'https://ipfs.feralfile.com/ipfs/',
      'https://ipfs.io/ipfs/',
      'https://cloudflare-ipfs.com/ipfs/'
    ];

    async function resolveIpfsUrl(ipfsUrl) {
      if (!ipfsUrl.startsWith('ipfs://')) return ipfsUrl; // already HTTP(S)

      // ipfs://CID/path → CID, path
      const withoutScheme = ipfsUrl.slice('ipfs://'.length);
      const [cid, ...rest] = withoutScheme.split('/');
      const path = rest.join('/');

      const attempts = IPFS_GATEWAYS.map(gw => (async () => {
        const httpUrl = gw + cid + (path ? '/' + path : '');
        try {
          const res = await fetch(httpUrl, { method: 'HEAD' });
          if (!res.ok) {
            throw new Error('HTTP status ' + res.status);
          }
          log('Using IPFS gateway', httpUrl);
          return httpUrl;
        } catch (e) {
          log('Gateway failed', { gateway: gw, error: String(e) });
          throw e;
        }
      })());

      try {
        // Race all gateways in parallel, take the first success
        return await firstSuccessful(attempts);
      } catch (e) {
        // Fallback: just use the first gateway; browser may still succeed.
        log('All gateway checks failed, falling back to first gateway', String(e));
        return IPFS_GATEWAYS[0] + withoutScheme;
      }
    }

    async function resolveClipBase(clipDirectoryUrl) {
      // If clipDirectoryUrl is ipfs://..., convert to working HTTP URL for the *directory*.
      if (clipDirectoryUrl.startsWith('ipfs://')) {
        // Turn ipfs://CID into https://gateway/ipfs/CID and just return the base.
        const withoutScheme = clipDirectoryUrl.slice('ipfs://'.length);
        const [cid] = withoutScheme.split('/');
        const base = 'ipfs://' + cid; // normalize to ipfs://CID, then resolve once
        // Use the actual tokenId instead of a dummy token
        return await resolveIpfsUrl(base);
      }
      return clipDirectoryUrl.replace(/\/$/, '');
    }

    // -----------------------------
    // 2. JSON-RPC helpers (no web3 lib)
    // -----------------------------

    const ETH_RPC_ENDPOINTS = [
      'https://eth.llamarpc.com',
      'https://cloudflare-eth.com',
      'https://rpc.ankr.com/eth',
      'https://ethereum-rpc.publicnode.com',
      'https://rpc.mevblocker.io',
      'https://eth.drpc.org'
    ];

    // Helper: encode uint256 as 32-byte hex string (no 0x prefix)
    function encodeUint256(value) {
      const bn = BigInt(value);
      let hex = bn.toString(16);
      if (hex.length > 64) {
        throw new Error('uint256 too large');
      }
      while (hex.length < 64) hex = '0' + hex;
      return hex;
    }

    // Function selector for seriesArtworksOfOwner(uint256)
    // keccak256("seriesArtworksOfOwner(uint256)").slice(0, 4)
    const SERIES_OF_OWNER_SELECTOR = '0x07b07ae9';

    async function ethCallWithFallback(contract, data) {
      const payload = {
        jsonrpc: '2.0',
        id: 1,
        method: 'eth_call',
        params: [
          {
            to: contract,
            data: data
          },
          'latest'
        ]
      };

      const attempts = ETH_RPC_ENDPOINTS.map(url => (async () => {
        try {
          const res = await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
          });
          if (!res.ok) {
            log('RPC HTTP error', { url, status: res.status });
            throw new Error('HTTP status ' + res.status);
          }
          const json = await res.json();
          if (json.error) {
            log('RPC JSON error', { url, error: json.error });
            throw new Error('RPC error ' + JSON.stringify(json.error));
          }
          log('RPC success', { url });
          return json.result;
        } catch (e) {
          log('RPC network error', { url, error: String(e) });
          throw e;
        }
      })());

      // Race all RPCs in parallel, take the first successful result
      return firstSuccessful(attempts);
    }

    // Decode a dynamic uint256[] from ABI-encoded return data
    function decodeUint256Array(hexData) {
      if (!hexData || hexData === '0x') return [];
      let hex = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
      // For a single dynamic uint256[] return, layout is:
      // 0x
      // [0..31]   offset to array data (usually 0x20)
      // [32..63]  length (N)
      // [64..]    N * 32-byte elements

      const offset = BigInt('0x' + hex.slice(0, 64));
      const lengthPos = Number(offset) * 2; // hex chars per byte = 2
      const length = BigInt('0x' + hex.slice(lengthPos, lengthPos + 64));

      const result = [];
      let cursor = lengthPos + 64; // first element
      for (let i = 0n; i < length; i++) {
        const word = hex.slice(cursor, cursor + 64);
        result.push(BigInt('0x' + word));
        cursor += 64;
      }
      return result;
    }

    async function fetchSeriesArtworksOfOwner(contract, tokenId) {
      const data = SERIES_OF_OWNER_SELECTOR + encodeUint256(tokenId);
      const raw = await ethCallWithFallback(contract, data);
      const arr = decodeUint256Array(raw);
      log('seriesArtworksOfOwner result', arr.map(String));
      return arr.map(String); // convert BigInt[] → string[]
    }

    // -----------------------------
    // 3. Playback logic
    // -----------------------------

    async function main() {
      const { contract, tokenId, mode, clipDirectoryUrl } = getQueryParams();
      log('Query params', { contract, tokenId, mode, clipDirectoryUrl });

      if (!tokenId) {
        log('Missing token_id; nothing to play');
        return;
      }

      const clipBase = await resolveClipBase(clipDirectoryUrl);
      const player = document.getElementById('player');

      if (mode === 'scene') {
        // Simple case: single clip for this token
        const url = clipBase + '/' + tokenId;
        log('Playing scene clip', url);
        player.src = url;
        player.play().catch(e => log('Play error', String(e)));
        return;
      }

      if (!contract) {
        log('Mode is episode but contract is missing; falling back to scene');
        const url = clipBase + '/' + tokenId;
        player.src = url;
        player.play().catch(e => log('Play error', String(e)));
        return;
      }

      // Episode mode: call contract and play multiple clips
      let tokenIds;
      try {
        tokenIds = await fetchSeriesArtworksOfOwner(contract, tokenId);
      } catch (e) {
        log('Failed to load series tokens; falling back to single scene', String(e));
        const url = clipBase + '/' + tokenId;
        player.src = url;
        player.play().catch(err => log('Play error', String(err)));
        return;
      }

      if (!tokenIds.length) {
        log('Empty series result; falling back to single scene');
        const url = clipBase + '/' + tokenId;
        player.src = url;
        player.play().catch(err => log('Play error', String(err)));
        return;
      }

      // Simple linear playlist over the owner’s tokens
      let index = 0;
      const playlist = tokenIds;

      async function playCurrent() {
        const tid = playlist[index];
        const url = clipBase + '/' + tid;
        log('Playing episode clip', { index, tokenId: tid, url });
        player.src = url;
        try {
          await player.play();
        } catch (e) {
          log('Play error', String(e));
        }
      }

      player.addEventListener('ended', () => {
        index = (index + 1) % playlist.length; // loop
        playCurrent();
      });

      await playCurrent();
    }

    main().catch(e => log('Fatal error', String(e)));
  </script>
</body>
</html>

6. Notes for Integration with Feral File

  • Feral File will:

    • Provide the final clip_directory_url default (IPFS CID).
    • Provide correct contract and token_id query parameters when embedding the work.
    • Ensure the contract implements seriesArtworksOfOwner with the expected semantics.
  • You are free to:

    • Change the visual design, layout, and timing of clips.
    • Add additional UI elements (e.g., captions, overlays) as long as they don’t break the query‑param contract.
    • Refactor code internally, as long as the external behavior described above stays the same.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment