> ## Documentation Index
> Fetch the complete documentation index at: https://docs.astral.global/llms.txt
> Use this file to discover all available pages before exploring further.

# Location-Gated NFT

> Build an NFT that requires physical presence to mint

<Warning>
  **Research Preview** — Code snippets need testing against actual implementation.
</Warning>

# 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.

<Info>
  **About location verification**: This guide uses GPS coordinates as input. GPS is spoofable. We're working on [Location Proof plugins](https://collective.flashbots.net/t/towards-stronger-location-proofs/5323) that will replace `navigator.geolocation` for stronger verification — these are still in development.
</Info>

## 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.

```typescript theme={null}
import { AstralSDK } from '@decentralized-geo/astral-sdk';

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

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

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

***

## Step 2: Deploy the Resolver Contract

```solidity theme={null}
// 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

```typescript theme={null}
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);
```

<Warning>
  **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.
</Warning>

***

## Step 4: Frontend Integration

<Warning>
  **Location source**: The `navigator.geolocation` API provides GPS coordinates which are spoofable. In production, replace with [Location Proof plugins](https://collective.flashbots.net/t/towards-stronger-location-proofs/5323) as they become available.
</Warning>

```typescript theme={null}
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.onchain.create({
    location: { type: 'Point', coordinates: userCoords }
  });

  // 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 { uid } = await astral.compute.submit(result.delegatedAttestation);

  return {
    attestationUID: uid
  };
}
```

***

## Step 5: React Component

```tsx theme={null}
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 Type              | inputRef Value                                          |
| ----------------------- | ------------------------------------------------------- |
| Onchain attestation UID | The attestation UID directly                            |
| Raw GeoJSON             | `keccak256(abi.encode(geojsonString))` — a content hash |

<Info>
  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.
</Info>

***

## Common Errors

| Error                 | Selector     | Cause                                                                           | Solution                                                                |
| --------------------- | ------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `InvalidSignature()`  | `0x8baa579f` | `revocable` mismatch between signed data and submission                         | Ensure you submit with `revocable: true`                                |
| `Irrevocable()`       | `0x157bd4c3` | Schema registered with `revocable: false` but attestation has `revocable: true` | Re-register schema with `revocable: true`                               |
| `WrongOperation()`    | Custom       | Checking `operation == "within"` but API returns `within:500`                   | Use prefix matching with `_startsWith()`                                |
| `WrongLocation()`     | Custom       | `inputRefs[1]` doesn't match expected landmark                                  | Verify landmark UID; check if using raw GeoJSON (content hashes differ) |
| `NotFromAstral()`     | Custom       | Attestation not signed by Astral's TEE                                          | Check `astralSigner` matches chain's TEE address                        |
| `AttestationTooOld()` | Custom       | Timestamp older than 1 hour                                                     | User 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

<Card title="Next: Geofenced Token" icon="coins" href="/guides/geofenced-token">
  Build a token with geographic restrictions
</Card>
