Developing with the Location Protocol Framework#

In this notebook, we will walk through the end-to-end process of creating cryptographically verifiable location attestations. We’ll cover:

  1. Setup: Configuring your environment.

  2. Generating Geospatial Artifacts: Creating data from APIs (USGS Earthquakes) and interactive maps.

  3. Extending Payloads: Adding metadata and linking to IPFS.

  4. Creating Attestations: Signing off-chain and on-chain records.

  5. Example Application: Putting it all together.

Cant wait to get started?

Head over to and clone the location-protocol-tutorials repo and jump right in!

What is the Location Protocol?#

The Location Protocol is an open standard for creating portable, cryptographically signed records that represent spatial information. It serves as a bridge between traditional geospatial standards, such as GeoJSON and Open Geospatial Consortium (OGC), and Web3 systems, defining a data structure that ensures location data is portable, verifiable, and implementation-agnostic.

The astral-sdk is a TypeScript library that serves as a reference implementation of this standard. While the protocol defines the “rules,” the SDK provides the tooling to easily follow them. It integrates into existing application workflows, handling the complexity of formatting, encoding, and cryptographically signing geospatial data. By abstracting these technical requirements, the SDK allows developers to turn raw location inputs—such as data from APIs, sensor feeds, or user interactions—into protocol-compliant, verifiable attestations without needing to build the cryptographic infrastructure from scratch.

1. Setting up#

Jupyter + TypeScript#

Jupyterlab is a flexible workspace for writing code, running notebooks, working with data, and more. One of it’s recognizable features is it’s interactive environment, enabling users to run snippets of code and see the results in real-time. Perfect for prototyping and testing code. You may be wondering why I’m using Jupyterlab for this tutorial to run TypeScript code? Well, that’s where Deno comes in.

Installing Deno#

Deno is an open source runtime for JavaScript, TypeScript, and WebAssembly to run code without additional configuration, other than installing the Deno CLI. What that means is that you can run TypeScript code and get all the benefits of a modern runtime without having to install Node.js, manage package dependencies, and dealing with tsconfig.json for transpiling code to JavaScript.

Out of the box, Deno contains built-in support for the Jupyter kernel, allowing us to run TypeScript code in a Jupyter notebook.

To install Deno, run the following command:

On Linux or macOS:

curl -fsSL https://deno.land/install.sh | sh

On Windows:

iwr https://deno.land/install.ps1 -useb | iex

After installing Deno, you can verify the installation by running the following command:

deno --version

and ensure that you have the Jupyter kernel installed by running deno jupyter and you should see something like the following:

 Deno kernel already installed

Installing Jupyterlab#

While Deno provides a Jupyter kernel, we still need to setup a python environment to run Jupyterlab.

Create a python virtual environment (3.12.10 or higher), activate it, and install the dependencies defined in pyproject.toml.

python -m venv venv
source .venv/bin/activate
pip install .

To activate the virtual environment on Windows, run the following command ..venv:nbsphinx-math:Scripts\activate

Once installed, run deno run jupyter or jupyter lab to start the jupyter lab environment and open location_protocol_tutorial.ipynb.

Quick Primer to Web3 Interactions on the Ethereum Blockchain#

For developers new to Web3, let’s cover a few basic concepts before we dive into the code.

Web3 Wallets#

A Web3 wallet is what gives users identiy and allows them to interact with decentralized applications (dApps) on the blockchain, manage digital assets, and sign transactions. A Web3 wallet is addressable by a public key, which is a wallet address, and each wallet address has a corresponding private key, derived from your public key using cryptographic hashing algorithms, that’s used to sign transactions and prove ownership of the wallet address.

Important!

A private key is a secret key that is used to sign transactions and prove ownership of a wallet address. Never share your private key with anyone, as it gives them full control over your wallet and the assets stored in it.

There are a couple types of Web3 wallets to explore with a wide array of uses cases and user preferences, custodial and non-custodial. Custodial wallets store your private key on a third party, while non-custodial wallets store your private key on your device. The key distinction between custodial and non-custodial wallets is who controls ownership of the private key. Custodial wallets are more convenient, but non-custodial wallets are more secure since they are managed by the user.

Interacting with the Ethereum Blockchain#

When working with the Ethereum blockchain, an application needs to interact with it through an RPC provider such as Infura, Alchemy, or QuickNode, which provides access to Ethereum nodes on your behalf. These providers act as intermediaries, allowing your application to make requests to the Ethereum blockchain and returning responses.

To communicate with these providers, you use an RPC URL, which is essentially the address of the Ethereum node you’re connecting to. This URL determines which network (like Ethereum mainnet or testnet like Sepolia) your application will interact with.

To ensure secure and authorized access, you’ll need an RPC API key, which acts like a password. This key is included in your RPC URL to authenticate your requests and ensure only authorized users can access the network through the provider.

Finally, you’ll choose a network that best meets your applications specific requirements such as security, cost, scalability, and ecosystem compatibility. Each network has it’s own RPC URL and RPC API key, provided by the RPC provider.

Network options: Generally speaking, network choices are between layer 1 (L1) and layer 2 (L2) networks. L1 networks are the most secure and reliable (e.g. Ethereum mainnet), but the gas fees, the cost of a transaction, are high while only processing 15-20 transactions per second. L2 networks (e.g. Optimism, Arbitrum, Base) give up a little bit of security and reliability, but the gas fees are at a fraction of L1 network costs and scale higher for faster processing of transactions, making them ideal for applications that require high throughput and low costs.

Getting the environment configured#

For this tutorial, you’ll need a wallet private key and an RPC provider API key (e.g., from Infura, Alchemy, or QuickNode).

We will use the Sepolia testnet for this tutorial, which is a L2 network with a chain ID of 11155111.

Testnet funding: If you just created a new wallet, you will need to add funds for the network you are planning to use. For development purposes, you can use a testnet such as Sepolia and deposit some free testnet ETH from a faucet service, like The Google Cloud Web3 Faucet, Quicknode. Some faucets require having some ETH in your wallet sufficient mainnet activity to qualify for free testnet ETH. If you don’t meet the eligibility criteria, you can use alternatives like Sepolia PoW Faucet to mine free testnet ETH.

Environment Configuration#

In order to create on-chain attestations with the Astral SDK, this notebook references a .env file for necessary configuration values. The repository contains a .env-example file that you can use as a template by copying and to the same directory and renaming it to .env. Fill in the values for the following content:

PRIVATE_KEY=your_private_key_here
RPC_PROVIDER_API_KEY=your_rpc_provider_api_key_here
RPC_PROVIDER_URL=your_rpc_provider_url_here
RPC_CHAIN_ID=your_rpc_chain_id_here

Note: The astral-sdk currently supports the following Ethereum Attestation Service (EAS) networks:

  • Ethereum Mainnet (Chain ID: 1)

  • Sepolia Ethereum Testnet (Chain ID: 11155111)

  • Base (Chain ID: 8453)

  • Optimism (Chain ID: 10)

  • Arbitrum (Chain ID: 42161)

  • Celo (Chain ID: 42220)

After setting up your environment, running the cell below will load the environment variables into the notebook.

[1]:
import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts";

// Check if .env file exists.
try {
  Deno.statSync(".env");
} catch (error) {
  if (error instanceof Deno.errors.NotFound) {
    console.warn("⚠️ .env file not found. Have you copied the .env.example file to .env?");
  }
}

// Load environment variables
const env = await load();
const PRIVATE_KEY = env["PRIVATE_KEY"] || Deno.env.get("PRIVATE_KEY");
const RPC_PROVIDER_API_KEY = env["RPC_PROVIDER_API_KEY"] || Deno.env.get("RPC_PROVIDER_API_KEY");
const RPC_PROVIDER_URL = env["RPC_PROVIDER_URL"] || Deno.env.get("RPC_PROVIDER_URL");

if (!PRIVATE_KEY || !RPC_PROVIDER_API_KEY || !RPC_PROVIDER_URL) {
  console.warn("⚠️ Missing PRIVATE_KEY or RPC_PROVIDER_API_KEY or RPC_PROVIDER_URL. On-chain examples will not work.");
} else {
  console.log("✅ Environment variables loaded.");
}

// concatenate rpc provider url and api key
const rpcProviderUrl = RPC_PROVIDER_URL + RPC_PROVIDER_API_KEY;
const RPC_CHAIN_ID = env["RPC_CHAIN_ID"] || Deno.env.get("RPC_CHAIN_ID");

// Grab the name of the chain from the dictionary mapping chainId to chainName
const chainIdToChainName = {
  "8453": "base",
  "10": "optimism",
  "42161": "arbitrum",
  "42220": "celo",
  "11155111": "sepolia",
};
const CHAIN_NAME = chainIdToChainName[RPC_CHAIN_ID];

✅ Environment variables loaded.

Importing Packages and Dependencies#

Deno imports packages using specifiers like npm:, jsr:, and https: to resolve and fetch dependencies. The npm: specifier imports packages from the npm registry, jsr: from the JSR registry, and https: from remote URLs like esm.sh and unpkg.com, with Deno resolving these during import and caching them locally.

We’ll be using astral-sdk for the tooling to manage and create location attestations and ethers for wallet management.

Why esm.sh?#

You might notice we are importing packages from https://esm.sh. Deno uses URL-based imports and treats remote modules as first-class citizens. esm.sh is a CDN that automatically converts CommonJS npm packages into standard ES Modules (ESM) that Deno can execute natively. This eliminates the need for a package.json file or a node_modules folder for this tutorial.

The astral-sdk package has a dependency on the eas-sdk which is not compatible with the npm: specifier. Importing from esm.sh allows us to import both packages, resolving the issue.

First, let’s ensure our Deno environment is ready and import the necessary packages and modules.

[3]:
// Import libraries via esm.sh
import { AstralSDK } from "https://esm.sh/@decentralized-geo/astral-sdk";
// import { ethers } from "npm:ethers@6";
import { ethers, Wallet } from "https://esm.sh/ethers@6";

console.log("✅ Libraries imported successfully!");
console.log("Deno version:", Deno.version.deno);

✅ Libraries imported successfully!
Deno version: 2.5.6

2. Generating Geospatial Artifacts#

Before we can attest to geospatial data, we need the location data itself. This can come from sources such as:

  1. API Data Feeds: Automated sensors, government databases, or existing APIs.

  2. User Input: Interactive maps where users draw points, lines, or polygons.

Let’s explore both.

Option A: Fetching Data from an API (USGS Earthquakes)#

We’ll fetch real-time earthquake data, one of the many available real-time API data feeds provided by the U.S. Geological Survey (USGS). This simulates a scenario where an automated system may attest to environmental events coming from a reputable, authortative source.

[4]:
// Fetch earthquakes with magnitude 4.5+ from the last month
const USGS_URL = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_month.geojson";

console.log("Fetching earthquake data...");
const response = await fetch(USGS_URL);
const data = await response.json();

// Get the most recent earthquake
const recentQuake = data.features[0];

console.log(`Found ${data.features.length} earthquakes.`);
console.log(`Most recent: ${recentQuake.properties.title}\n\n`);
console.log("Raw geojson:\n\n", recentQuake);

// We will use this 'recentQuake' object as our artifact for the attestation later.

Fetching earthquake data...
Found 464 earthquakes.
Most recent: M 4.9 - Volcano Islands, Japan region


Raw geojson:

 {
  type: "Feature",
  properties: {
    mag: 4.9,
    place: "Volcano Islands, Japan region",
    time: 1765343929438,
    updated: 1765345138040,
    tz: null,
    url: "https://earthquake.usgs.gov/earthquakes/eventpage/us6000rttd",
    detail: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us6000rttd.geojson",
    felt: null,
    cdi: null,
    mmi: null,
    alert: null,
    status: "reviewed",
    tsunami: 0,
    sig: 369,
    net: "us",
    code: "6000rttd",
    ids: ",us6000rttd,",
    sources: ",us,",
    types: ",origin,phase-data,",
    nst: 56,
    dmin: 4.051,
    rms: 0.79,
    gap: 99,
    magType: "mb",
    type: "earthquake",
    title: "M 4.9 - Volcano Islands, Japan region"
  },
  geometry: { type: "Point", coordinates: [ 143.5841, 23.2312, 39.66 ] },
  id: "us6000rttd"
}

Option B: Interactive Map Input#

In a user-facing application, you might want users to select a location or manually draw a feature on a map. The captured data is what you would pass to the astral-sdk to construct the location payload.

Below is an example of how you could embed a Leaflet map to capture user input. To get a sense of what the captured data looks like, the drawn feature is displayed in the output area.

[6]:
// To ensure the map renders on the first run, we can use an iframe to isolate the map's context.
// This prevents conflicts with the notebook's environment and ensures scripts load correctly.

const mapHtml = `
<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
    <style>
        body { margin: 0; padding: 0; }
        #map { height: 400px; width: 100%; }
    </style>
</head>
<body>
    <div id="map"></div>
    <script>
        window.onload = function() {
            // var map = L.map('map').setView([37.7749, -122.4194], 13);
            var map = L.map('map').setView([${recentQuake.geometry.coordinates[1]}, ${recentQuake.geometry.coordinates[0]}], 6);
            L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                attribution: '&copy; OpenStreetMap contributors'
            }).addTo(map);

            var drawnItems = new L.FeatureGroup();
            map.addLayer(drawnItems);

            var drawControl = new L.Control.Draw({
                edit: { featureGroup: drawnItems }
            });
            map.addControl(drawControl);

            map.on(L.Draw.Event.CREATED, function (e) {
                var layer = e.layer;
                drawnItems.addLayer(layer);
                const geoJson = layer.toGeoJSON();
                window.parent.postMessage({ type: 'geojson', data: geoJson }, '*');
            });
        };
    </script>
</body>
</html>
`;

await Deno.jupyter.html`
<iframe
    srcdoc="${mapHtml.replace(/"/g, '&quot;')}"
    style="width: 100%; height: 420px; border: none;">
</iframe>
<script>
    window.addEventListener('message', function(event) {
        if (event.data && event.data.type === 'geojson') {
            document.getElementById('output-area').textContent = JSON.stringify(event.data, null, 4);
        }
    });
</script>
<p>Draw a feature on the map to view the resulting GeoJSON</p>
<div id="output-area" style="white-space: pre; background: #f0f0f0; padding: 10px; margin-top: 10px;">
    geojson is displayed here...
</div>

`;

[6]:

Draw a feature on the map to view the resulting GeoJSON

geojson is displayed here...

Extracting the geospatial components#

Now that we have a sense of what the raw data looks like in either case, we can extract the geospatial components and other relevant data to construct the location payload.

The key data elements from the above examples that are relevant to the location payload are:

  1. The raw location data from the API endpoint or the map input

  2. The location type defining how the location data is formatted (e.g., GeoJSON, address, coordinate-decimal)

What isn’t listed is the spatial reference system (SRS), also known as a coordinate reference system (CRS). This is important for the location payload as it defines the coordinate system of the location data, or more simply, how to convert the spatial coordinates into real-world coordinates. Without it, coordinates are meaningless.

Understanding SRS/CRS for Web Mapping#

When working with frontend mapping libraries like Leaflet or Mapbox GL JS, you will encounter two common coordinate systems. EPSG:4326 (WGS84) uses decimal degrees (Longitude/Latitude) and is the standard for storing and transmitting geospatial data, including the Location Protocol. EPSG:3857 (Web Mercator) uses meters and is used internally by mapping libraries for rendering tiles and displaying features on screen.

The key insight: mapping libraries automatically handle the conversion between these systems. When you draw a shape, select a point, or fetch data from an API data feed, Leaflet and Mapbox GL JS return coordinates to your code in EPSG:4326 by default, as standard GeoJSON. For example, the USGS earthquake API returns GeoJSON responses with coordinates already in EPSG:4326, and when you use Leaflet’s .toGeoJSON() method or Mapbox GL Draw’s draw.getAll(), the resulting coordinates are also in EPSG:4326.

For most frontend workflows, you can safely assume coordinates from interactive map drawing tools and standard API data feeds are in EPSG:4326 and follow the coordinate order conventions: Leaflet returns [Latitude, Longitude] from getLatLng() but [Longitude, Latitude] from toGeoJSON(), while Mapbox GL JS always uses [Longitude, Latitude]. To avoid confusion, always extract GeoJSON from Leaflet using .toGeoJSON() to match the Mapbox and Location Protocol convention.

For data from third-party API feeds or sensor sources not covered by standard GeoJSON responses, always consult the source documentation to confirm the coordinate system. Most modern geospatial APIs default to EPSG:4326, but some government or legacy systems might use regional projections.

3. Preparing the Location Payload#

Now that we have our geospatial artifact (using the latest earthquake), we need to transform it into a Location Payload compatible with the protocol.

The Location Payload is a self describable data artifact for location information, containing metadata properties ensuring the data is parsable.

  • srs: The Spatial Reference System identifier (e.g., EPSG:4326) that defines the coordinate system.

  • locationType: An identifier for the format of the location data (e.g., coordinate-decimal, geojson).

  • location: The geospatial data itself, formatted according to locationType.

  • specVersion: The version of the Location Protocol specification the payload adheres to.

These essential properties ensure the data is unambigious, and can be parsed by any Location Protocol compatible client.

We can also attach additional metadata, such as IPFS Content Identifiers (CIDs) for media or large datasets.

Note: astral-sdk does not handle IPFS uploads directly. You should upload your content to IPFS (using services like Storacha or Filebase) separately and obtain the CID.

[7]:
// Construct the Location Payload from the USGS data
const locationPayload = {
  srs: "EPSG:4326", // Standard WGS84 coordinates
  locationType: "geojson",
  location: {
    type: "Point",
    coordinates: recentQuake.geometry.coordinates.slice(0, 2), // [lon, lat],
  },
  eventTimestamp: new Date(recentQuake.properties.time).toISOString(),
  mediaData: "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco",
  mediaType: "application/json",
  specVersion: "0.1.0",
}

console.log("Constructed Payload:", locationPayload);

Constructed Payload: {
  srs: "EPSG:4326",
  locationType: "geojson",
  location: { type: "Point", coordinates: [ 143.5841, 23.2312 ] },
  eventTimestamp: "2025-12-10T05:18:49.438Z",
  mediaData: "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco",
  mediaType: "application/json",
  specVersion: "0.1.0"
}

4. Creating and Submitting Location Attestation#

We can now create an attestation. There are two types:

  1. Off-chain: A cryptographically signed message. Free, fast, and private. Good for P2P sharing.

  2. On-chain: Stored on the Ethereum blockchain (or L2s). Permanent, public, and composable with smart contracts.

[8]:
// Initialize SDK with a random wallet if no env vars are set (for demo purposes)
let wallet;
if (PRIVATE_KEY) {
    wallet = new ethers.Wallet(PRIVATE_KEY);
} else {
    console.log("Creating temporary random wallet for demo...");
    wallet = ethers.Wallet.createRandom();
}

const sdk = new AstralSDK({ signer: wallet });

// 1. Create Off-chain Attestation
console.log("Creating off-chain attestation...");
const offchainRecord = await sdk.createOffchainLocationAttestation({
    ...locationPayload,
    memo: "USGS Earthquake Report (Off-chain)"
});

console.log("✅ Off-chain Record Created!");
console.log("UID:", offchainRecord.uid);
console.log("Signature:", offchainRecord.signature);

Creating off-chain attestation...
✅ Off-chain Record Created!
UID: 0x15a5c30e57bbf367d4a0a83300aa5b5769848bd08664549894e4d00c8eba3582
Signature: {"v":27,"r":"0xca8c225cf74d6560f4d6b63427a9a1addaed4e09db087bebc86f1b5b7768945d","s":"0x41c32ffde3978a26c993a73ffec9e52ec01e6a4525eb97a68889849a70ba82e6"}
[9]:
// 2. Create On-chain Attestation (Requires valid config)
console.log(`\nCreating on-chain attestation (Network: ${CHAIN_NAME})...`);

const provider = new ethers.JsonRpcProvider(`${rpcProviderUrl}`);
const connectedWallet = wallet.connect(provider);

const onchainSdk = new AstralSDK({
    signer: connectedWallet,
    chainId: RPC_CHAIN_ID
});

const onchainRecord = await onchainSdk.createOnchainLocationAttestation({
    ...locationPayload,
    memo: "USGS Earthquake Report (On-chain)"
});

console.log("✅ On-chain Record Created!");
console.log("Tx Hash:", onchainRecord.txHash);
console.log(`View on EAS Scan: https://${CHAIN_NAME}.easscan.org/attestation/view/${onchainRecord.uid}`);


Creating on-chain attestation (Network: sepolia)...
✅ On-chain Record Created!
Tx Hash: 0x0693a9ab86ba7d9d11ae2896250d3df806cff93f94f513f9b08d4a08cacf98e0
View on EAS Scan: https://sepolia.easscan.org/attestation/view/0xbde63551f5c09054a533e196a20bedf423773b5a6027c056f67c9c7c35d00507

5. Example Applications#

You can build powerful applications by combining these primitives. For a more complex, full-stack example, check out the Privy + EAS Integration Demo.

Below is a simplified flow of how an app might handle a user checking in and creating an off-chain locationattestation:

[10]:
async function handleUserCheckin(userWallet, coordinates, note) {
    console.log(`Processing check-in for ${userWallet.address}...`);

    const appSdk = new AstralSDK({ signer: userWallet });

    const checkin = await appSdk.createOffchainLocationAttestation({
        location: {
            type: 'Point',
            coordinates: coordinates
        },
        memo: note
    });

    return checkin.uid;
}

// Simulate a check-in
const checkinId = await handleUserCheckin(wallet, [-73.935242, 40.730610], "Coffee shop visit");
console.log("New Check-in ID:", checkinId);

Processing check-in for 0x3074C8732366cE5DB80986aBA8FB69897872DdB9...
New Check-in ID: 0x0334b581ba19531c1028b6a97ba94b8a8f9104a6230eee06966eece7d1a403b0

Wrapping Up#

You’ve now learned how to:

  1. Fetch geospatial data from APIs.

  2. Capture user location input.

  3. Build and prepare the location payload

  4. Sign and submit verifiable location attestations (on and off-chain).

The astral-sdk makes it easy to integrate verifiable location data into your existing applications, creating immutable and verifiable location data in a secure and transparent way.