Skip to main content
Research Preview — Code snippets need testing against actual implementation.

Build a Location-Gated NFT

Create an NFT collection where minting requires passing a geospatial policy check — verifying the user is within range of a target location.
About location verification: This guide uses GPS coordinates as input. GPS is spoofable. We’re working on Location Proof plugins that will replace navigator.geolocation for stronger verification — these are still in development.

Overview

This guide walks through:
  1. Setting up a reference location
  2. Creating the resolver contract
  3. Building the frontend
  4. Handling the mint flow

Step 1: Set Up the Reference Location

First, create a location attestation for the target location. This could be a permanent landmark, or a dynamic location that changes.
import { AstralSDK } from '@decentralized-geo/astral-sdk';

const astral = new AstralSDK({ chainId: 84532, signer: wallet });

// Create the landmark location
const landmark = await astral.location.create({
  type: 'Point',
  coordinates: [-122.4194, 37.7749]  // San Francisco
}, {
  submitOnchain: true,
  metadata: {
    name: "SF Visitor Center",
    description: "San Francisco Welcome NFT location"
  }
});

console.log('Landmark UID:', landmark.uid);
// Store this UID for your contract

Step 2: Deploy the Resolver Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@eas/contracts/resolver/SchemaResolver.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract LocationGatedNFT is SchemaResolver, ERC721, Ownable {
    address public astralSigner;
    bytes32 public landmarkUID;
    uint256 public radius;  // in centimeters (for precision)
    uint256 public nextTokenId = 1;

    mapping(address => bool) public hasMinted;
    mapping(bytes32 => bool) public usedAttestations;

    error NotFromAstral();
    error AlreadyUsed();
    error WrongOperation();
    error InvalidInputs();
    error WrongLocation();
    error AttestationTooOld();
    error NotCloseEnough();
    error AlreadyMinted();

    event NFTMinted(address indexed recipient, uint256 tokenId, bytes32 attestationUID);

    constructor(
        IEAS eas,
        address _astralSigner,
        bytes32 _landmarkUID,
        uint256 _radiusMeters
    )
        SchemaResolver(eas)
        ERC721("SF Visitor", "SFVISIT")
        Ownable(msg.sender)
    {
        astralSigner = _astralSigner;
        landmarkUID = _landmarkUID;
        radius = _radiusMeters * 100;  // Convert to cm
    }

    /// @dev Check if a string starts with a given prefix
    /// @notice Operation strings include parameters (e.g., "within:500" not "within")
    function _startsWith(string memory str, string memory prefix) internal pure returns (bool) {
        bytes memory strBytes = bytes(str);
        bytes memory prefixBytes = bytes(prefix);
        if (strBytes.length < prefixBytes.length) return false;
        for (uint256 i = 0; i < prefixBytes.length; i++) {
            if (strBytes[i] != prefixBytes[i]) return false;
        }
        return true;
    }

    function onAttest(
        Attestation calldata attestation,
        uint256 /*value*/
    ) internal override returns (bool) {
        // 1. Verify from Astral's TEE signer
        if (attestation.attester != astralSigner) revert NotFromAstral();

        // 2. Prevent replay
        if (usedAttestations[attestation.uid]) revert AlreadyUsed();
        usedAttestations[attestation.uid] = true;

        // 3. Decode policy attestation (BooleanPolicy for 'within')
        (
            bool policyPassed,
            bytes32[] memory inputRefs,
            uint64 timestamp,
            string memory operation
        ) = abi.decode(
            attestation.data,
            (bool, bytes32[], uint64, string)
        );

        // 4. Verify correct operation (uses prefix match - operation is "within:RADIUS")
        if (!_startsWith(operation, "within")) revert WrongOperation();

        // 5. Verify correct landmark was checked
        // Note: inputRefs are content hashes when using raw GeoJSON, or UIDs when using attestations
        if (inputRefs.length < 2) revert InvalidInputs();
        if (inputRefs[1] != landmarkUID) revert WrongLocation();

        // 6. Verify timestamp is recent (within 1 hour)
        if (timestamp < block.timestamp - 1 hours) revert AttestationTooOld();

        // 7. Verify policy passed (user is within radius)
        if (!policyPassed) revert NotCloseEnough();

        // 8. One mint per address
        if (hasMinted[attestation.recipient]) revert AlreadyMinted();
        hasMinted[attestation.recipient] = true;

        // 9. Mint NFT
        uint256 tokenId = nextTokenId++;
        _mint(attestation.recipient, tokenId);

        emit NFTMinted(attestation.recipient, tokenId, attestation.uid);
        return true;
    }

    function onRevoke(Attestation calldata, uint256)
        internal pure override returns (bool)
    {
        return false;
    }

    // Admin functions
    function updateAstralSigner(address _signer) external onlyOwner {
        astralSigner = _signer;
    }

    function updateRadius(uint256 _radiusMeters) external onlyOwner {
        radius = _radiusMeters * 100;
    }
}

Step 3: Register the Schema

import { SchemaRegistry } from '@ethereum-attestation-service/eas-sdk';

const schemaRegistry = new SchemaRegistry(SCHEMA_REGISTRY_ADDRESS);

// Boolean policy schema for 'within' operation
const schema = "bool result,bytes32[] inputRefs,uint64 timestamp,string operation";

const tx = await schemaRegistry.connect(signer).register({
  schema,
  resolverAddress: nftContract.address,
  revocable: true  // IMPORTANT: Must be true - Astral signs with revocable=true
});

const receipt = await tx.wait();
const SCHEMA_UID = receipt.logs[0].args.uid;

console.log('Schema UID:', SCHEMA_UID);
Schema must use revocable: true — Astral signs delegated attestations with revocable: true. If your schema is registered with revocable: false, EAS will reject the attestation with an Irrevocable() or InvalidSignature() error. See #9 for discussion on this behavior.

Step 4: Frontend Integration

Location source: The navigator.geolocation API provides GPS coordinates which are spoofable. In production, replace with Location Proof plugins as they become available.
import { AstralSDK } from '@decentralized-geo/astral-sdk';
import * as turf from '@turf/turf';

const LANDMARK_UID = '0x...';  // Your reference location
const SCHEMA_UID = '0x...';    // Your schema
const RADIUS_METERS = 500;

async function checkEligibility(userCoords: [number, number]) {
  const astral = new AstralSDK({ chainId: 84532 });

  // Quick local check first (UX)
  const landmark = await astral.location.get(LANDMARK_UID);
  const distance = turf.distance(
    turf.point(userCoords),
    turf.point(landmark.geometry.coordinates),
    { units: 'meters' }
  );

  if (distance > RADIUS_METERS) {
    return {
      eligible: false,
      message: `You're ${Math.round(distance)}m away. Get within ${RADIUS_METERS}m to mint!`
    };
  }

  return {
    eligible: true,
    message: `You're close enough! Ready to mint.`,
    distance
  };
}

async function mintNFT(userCoords: [number, number], wallet: Signer) {
  const astral = new AstralSDK({ chainId: 84532, signer: wallet });

  // Create user's location attestation
  const userLocation = await astral.location.create({
    type: 'Point',
    coordinates: userCoords
  }, {
    submitOnchain: true
  });

  // Compute proximity and submit attestation
  const result = await astral.compute.within(
    userLocation.uid,
    LANDMARK_UID,
    RADIUS_METERS,
    {
      schema: SCHEMA_UID,
      recipient: await wallet.getAddress()
    }
  );

  if (!result.result) {
    throw new Error('Location check failed - not close enough');
  }

  // Submit to EAS (triggers resolver → mints NFT)
  const tx = await astral.eas.submitDelegated(result.delegatedAttestation);
  const receipt = await tx.wait();

  return {
    transactionHash: tx.hash,
    attestationUID: result.attestation.uid
  };
}

Step 5: React Component

import { useState, useEffect } from 'react';
import { useAccount, useSigner } from 'wagmi';

function MintButton() {
  const { address } = useAccount();
  const { data: signer } = useSigner();
  const [status, setStatus] = useState<'checking' | 'eligible' | 'minting' | 'done'>('checking');
  const [message, setMessage] = useState('');

  useEffect(() => {
    // Get user's location
    navigator.geolocation.getCurrentPosition(async (position) => {
      const coords: [number, number] = [
        position.coords.longitude,
        position.coords.latitude
      ];

      const eligibility = await checkEligibility(coords);
      setMessage(eligibility.message);
      setStatus(eligibility.eligible ? 'eligible' : 'checking');
    });
  }, []);

  const handleMint = async () => {
    if (!signer) return;

    setStatus('minting');
    try {
      navigator.geolocation.getCurrentPosition(async (position) => {
        const coords: [number, number] = [
          position.coords.longitude,
          position.coords.latitude
        ];

        const result = await mintNFT(coords, signer);
        setStatus('done');
        setMessage(`NFT minted! TX: ${result.transactionHash}`);
      });
    } catch (error) {
      setStatus('eligible');
      setMessage(`Error: ${error.message}`);
    }
  };

  return (
    <div>
      <p>{message}</p>
      <button
        onClick={handleMint}
        disabled={status !== 'eligible'}
      >
        {status === 'minting' ? 'Minting...' : 'Mint NFT'}
      </button>
    </div>
  );
}

Understanding inputRefs

The inputRefs array in policy attestations identifies the inputs used for the computation. The format depends on how inputs were provided:
Input TypeinputRef Value
Onchain attestation UIDThe attestation UID directly
Raw GeoJSONkeccak256(abi.encode(geojsonString)) — a content hash
If your contract checks inputRefs[1] == landmarkUID, it will only work when the landmark was passed as an attestation UID, not as raw GeoJSON. For raw GeoJSON, you’d need to compute the expected content hash.

Common Errors

ErrorSelectorCauseSolution
InvalidSignature()0x8baa579frevocable mismatch between signed data and submissionEnsure you submit with revocable: true
Irrevocable()0x157bd4c3Schema registered with revocable: false but attestation has revocable: trueRe-register schema with revocable: true
WrongOperation()CustomChecking operation == "within" but API returns within:500Use prefix matching with _startsWith()
WrongLocation()CustominputRefs[1] doesn’t match expected landmarkVerify landmark UID; check if using raw GeoJSON (content hashes differ)
NotFromAstral()CustomAttestation not signed by Astral’s TEECheck astralSigner matches chain’s TEE address
AttestationTooOld()CustomTimestamp older than 1 hourUser needs to generate a fresh attestation

Security Considerations

  1. Timestamp validation: Contract requires attestation < 1 hour old
  2. Replay prevention: Track used attestation UIDs
  3. Input verification: Check that the expected landmark was used
  4. One mint per address: Prevent farming

Next: Geofenced Token

Build a token with geographic restrictions