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:- Setting up a reference location
- Creating the resolver contract
- Building the frontend
- 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.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
// 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.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.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
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
TheinputRefs 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 |
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
| 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
- Timestamp validation: Contract requires attestation < 1 hour old
- Replay prevention: Track used attestation UIDs
- Input verification: Check that the expected landmark was used
- One mint per address: Prevent farming
Next: Geofenced Token
Build a token with geographic restrictions