Research Preview — Code snippets need testing against actual implementation.
Build a Local Currency
Create a local currency that can only be traded by people physically in a specific region — enabling hyperlocal economies.About location verification: This guide uses GPS coordinates as input. GPS is spoofable. We’re working on Location Proof plugins that will replace
getCurrentLocation for stronger verification — these are still in development.Concept
Imagine a “Berlin Token” (BLN) that:- Can only be minted by people present in Berlin
- Can only be transferred to people currently in Berlin
- Creates a true local currency for the city
Step 1: Define the Region
Copy
import { AstralSDK } from '@decentralized-geo/astral-sdk';
const astral = new AstralSDK({ chainId: 84532, signer: wallet });
// Berlin city boundary (simplified)
const berlinBoundary = {
type: 'Polygon',
coordinates: [[[
[13.088, 52.338],
[13.761, 52.338],
[13.761, 52.675],
[13.088, 52.675],
[13.088, 52.338]
]]]
};
// Create region attestation
const region = await astral.location.onchain.create({
location: berlinBoundary,
memo: "Berlin city boundary for local currency"
});
console.log('Region UID:', region.uid);
Step 2: The Token Contract
Copy
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@eas/contracts/resolver/SchemaResolver.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract GeofencedToken is SchemaResolver, ERC20, Ownable {
address public astralSigner;
bytes32 public regionUID;
// Track pending operations
mapping(bytes32 => PendingMint) public pendingMints;
mapping(bytes32 => PendingTransfer) public pendingTransfers;
struct PendingMint {
address recipient;
uint256 amount;
bool exists;
}
struct PendingTransfer {
address from;
address to;
uint256 amount;
bool exists;
}
event MintRequested(bytes32 indexed requestId, address recipient, uint256 amount);
event TransferRequested(bytes32 indexed requestId, address from, address to, uint256 amount);
constructor(
IEAS eas,
address _astralSigner,
bytes32 _regionUID
)
SchemaResolver(eas)
ERC20("Berlin Token", "BLN")
Ownable(msg.sender)
{
astralSigner = _astralSigner;
regionUID = _regionUID;
}
// Request a mint - user must then prove they're in the region
function requestMint(uint256 amount) external returns (bytes32 requestId) {
requestId = keccak256(abi.encode(msg.sender, amount, block.timestamp));
pendingMints[requestId] = PendingMint({
recipient: msg.sender,
amount: amount,
exists: true
});
emit MintRequested(requestId, msg.sender, amount);
}
// Request a transfer - recipient must prove they're in the region
function requestTransfer(address to, uint256 amount) external returns (bytes32 requestId) {
require(balanceOf(msg.sender) >= amount, "Insufficient balance");
requestId = keccak256(abi.encode(msg.sender, to, amount, block.timestamp));
pendingTransfers[requestId] = PendingTransfer({
from: msg.sender,
to: to,
amount: amount,
exists: true
});
// Lock tokens during pending period
_transfer(msg.sender, address(this), amount);
emit TransferRequested(requestId, msg.sender, to, amount);
}
function onAttest(
Attestation calldata attestation,
uint256 /*value*/
) internal override returns (bool) {
require(attestation.attester == astralSigner, "Not from Astral");
// Decode policy attestation
(
bool inRegion,
bytes32[] memory inputRefs,
uint64 timestamp,
string memory operation
) = abi.decode(
attestation.data,
(bool, bytes32[], uint64, string)
);
require(inRegion, "Not in region");
require(inputRefs[0] == regionUID, "Wrong region");
require(timestamp > block.timestamp - 1 hours, "Too old");
// Extract request ID from recipient field (encoded)
bytes32 requestId = bytes32(uint256(uint160(attestation.recipient)));
// Check if this is a mint or transfer
if (pendingMints[requestId].exists) {
_executeMint(requestId);
} else if (pendingTransfers[requestId].exists) {
_executeTransfer(requestId);
} else {
revert("No pending operation");
}
return true;
}
function _executeMint(bytes32 requestId) internal {
PendingMint memory pending = pendingMints[requestId];
delete pendingMints[requestId];
_mint(pending.recipient, pending.amount);
}
function _executeTransfer(bytes32 requestId) internal {
PendingTransfer memory pending = pendingTransfers[requestId];
delete pendingTransfers[requestId];
_transfer(address(this), pending.to, pending.amount);
}
// Cancel pending operations
function cancelMint(bytes32 requestId) external {
require(pendingMints[requestId].recipient == msg.sender, "Not your request");
delete pendingMints[requestId];
}
function cancelTransfer(bytes32 requestId) external {
PendingTransfer memory pending = pendingTransfers[requestId];
require(pending.from == msg.sender, "Not your request");
delete pendingTransfers[requestId];
_transfer(address(this), pending.from, pending.amount);
}
function onRevoke(Attestation calldata, uint256) internal pure override returns (bool) {
return false;
}
}
Step 3: SDK Integration
Copy
async function mintLocalToken(amount: bigint, wallet: Signer) {
const astral = new AstralSDK({ chainId: 84532, signer: wallet });
const tokenContract = new Contract(TOKEN_ADDRESS, TOKEN_ABI, wallet);
// 1. Request mint
const tx1 = await tokenContract.requestMint(amount);
const receipt = await tx1.wait();
const requestId = receipt.logs[0].args.requestId;
// 2. Get user's current location
const coords = await getCurrentLocation();
// 3. Create location attestation
const userLocation = await astral.location.onchain.create({
location: { type: 'Point', coordinates: coords }
});
// 4. Prove they're in the region
const proof = await astral.compute.contains(
REGION_UID,
userLocation.uid,
{
schema: SCHEMA_UID,
recipient: requestId // Pass request ID as recipient
}
);
if (!proof.result) {
await tokenContract.cancelMint(requestId);
throw new Error('Not in the required region');
}
// 5. Submit proof → triggers mint
const tx2 = await astral.compute.submit(proof.delegatedAttestation);
await tx2.wait();
return { transactionHash: tx2.hash };
}
Alternative: Simpler Transfer Gate
For a simpler implementation, gate transfers directly:Copy
contract SimpleGeofencedToken is ERC20 {
IEAS public eas;
address public astralSigner;
bytes32 public regionUID;
bytes32 public schemaUID;
function transferWithProof(
address to,
uint256 amount,
bytes32 attestationUID
) external {
// Fetch attestation from EAS
Attestation memory att = eas.getAttestation(attestationUID);
// Verify
require(att.attester == astralSigner, "Invalid attester");
require(att.recipient == to, "Wrong recipient");
(bool inRegion, bytes32[] memory inputs, , ) = abi.decode(
att.data,
(bool, bytes32[], uint64, string)
);
require(inRegion, "Recipient not in region");
require(inputs[0] == regionUID, "Wrong region");
// Execute transfer
_transfer(msg.sender, to, amount);
}
}
Use Cases
Neighborhood Currency
Local money that stays in the community
Tourism Credits
Tokens only spendable within city limits
Regional Stablecoins
Pegged currencies for specific regions
Event Tokens
Festival/conference currency
Next: Delivery Verification
Build an escrow with location verification