In this tutorial, you will construct a fully portable Location Protocol payload and wrap it in a cryptographically verifiable EAS offchain attestation entirely offline — no network connection required. By the end, you will have a fully formed SignedOffchainAttestation object with a unique UID and a verified signature. No prior EAS or blockchain experience is needed.
- Dart ≥ 3.11 (install)
location_protocoladded topubspec.yaml(see Installation)
Before you start, here is the complete flow you will build:
sequenceDiagram
participant Code as Your Code
participant SD as SchemaDefinition
participant LP as LPPayload
participant OS as OffchainSigner
Code->>SD: SchemaDefinition(fields: [...])
SD-->>Code: schema (toEASSchemaString() ready)
Code->>LP: LPPayload(lpVersion:, srs:, locationType:, location:)
LP-->>Code: lpPayload (validated)
Code->>OS: OffchainSigner.fromPrivateKey(privateKeyHex:, chainId:, easContractAddress:)
OS-->>Code: signer
Code->>OS: signOffchainAttestation(schema:, lpPayload:, userData:)
OS->>OS: generate salt, ABI encode, hash, EIP-712 sign
OS-->>Code: signed (SignedOffchainAttestation)
Note right of OS: Contains uid, signer, signature, schemaUID
Code->>OS: verifyOffchainAttestation(signed)
OS-->>Code: VerificationResult { isValid: true }
Each step adds code to a single main() function. The complete program listing at the end of this tutorial shows the full program — you can jump straight there if you prefer to read ahead.
A schema defines the structure of your attestation data. Here you add two business fields; the library will automatically prepend the four LP base fields.
import 'package:location_protocol/location_protocol.dart';
void main() async {
// Define your business schema
// LP base fields (lp_version, srs, location_type, location) are auto-prepended
final schema = SchemaDefinition(
fields: [
SchemaField(type: 'uint256', name: 'timestamp'),
SchemaField(type: 'string', name: 'memo'),
],
);
print(schema.toEASSchemaString());
// => "string lp_version,string srs,string location_type,string location,uint256 timestamp,string memo"
}Expected output:
string lp_version,string srs,string location_type,string location,uint256 timestamp,string memo
An LP payload carries the four base fields that every Location Protocol attestation must include.
Add the following inside main, after the schema definition from Step 1:
Note: All steps build up a single
main()function. The complete listing at the end shows the full program.
// Create the LP payload — validates all 4 base fields on construction
final lpPayload = LPPayload(
lpVersion: '1.0.0',
srs: 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', // OGC CRS URI
locationType: 'geojson-point',
location: {'type': 'Point', 'coordinates': [-103.771556, 44.967243]},
);
print('Location type: ${lpPayload.locationType}');
print('Location: ${lpPayload.location}');
// If LPPayload construction succeeds, all 4 fields are valid.Expected output:
Location type: geojson-point
Location: {type: Point, coordinates: [-103.771556, 44.967243]}
Offchain signing uses EIP-712 typed data — the result is verifiable by anyone with your public address, without touching the blockchain.
Still inside main, add the following after lpPayload:
// A test private key — NEVER use a real key in source code
const testPrivateKey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const chainId = 11155111; // Sepolia
final easAddress = ChainConfig.forChainId(chainId)!.eas;
final signer = OffchainSigner.fromPrivateKey(
privateKeyHex: testPrivateKey,
chainId: chainId,
easContractAddress: easAddress,
);
final signed = await signer.signOffchainAttestation(
schema: schema,
lpPayload: lpPayload,
userData: {
'timestamp': BigInt.from(DateTime.now().millisecondsSinceEpoch ~/ 1000),
'memo': 'Tutorial checkpoint',
},
);
print('Attestation UID: ${signed.uid}');
print('Signer: ${signed.signer}');Expected output pattern:
Attestation UID: 0x<64 hex chars>
Signer: 0x<40 hex chars>
Note: The UID is different every time you run this, even with the same inputs. This is by design — the CSPRNG salt in EAS offchain attestation version 2 makes each UID globally unique.
Add the following after the signed variable:
final result = signer.verifyOffchainAttestation(signed);
print('Valid: ${result.isValid}');
print('Recovered signer: ${result.recoveredAddress}');
// The recovered address should match the signer's address
assert(result.isValid, 'Signature verification failed');
assert(
result.recoveredAddress.toLowerCase() == signer.signerAddress.toLowerCase(),
'Recovered address mismatch',
);Expected output:
Valid: true
Recovered signer: 0x<same hex chars as Signer above>
Finally, add the following after the verification:
// Inspect the full signed attestation
print('Schema UID: ${signed.schemaUID}');
print('Version: ${signed.version}');
print('Revocable: ${signed.revocable}');
print('r: ${signed.signature.r}');
print('s: ${signed.signature.s}');
print('v: ${signed.signature.v}');Expected output pattern:
Schema UID: 0x<64 hex chars>
Version: 2
Revocable: true
r: 0x<64 hex chars>
s: 0x<64 hex chars>
v: 27 (or 28)
Copy this into bin/tutorial.dart and run with dart run bin/tutorial.dart:
import 'package:location_protocol/location_protocol.dart';
void main() async {
// Step 1 — Define your schema
// LP base fields (lp_version, srs, location_type, location) are auto-prepended
final schema = SchemaDefinition(
fields: [
SchemaField(type: 'uint256', name: 'timestamp'),
SchemaField(type: 'string', name: 'memo'),
],
);
print(schema.toEASSchemaString());
// => "string lp_version,string srs,string location_type,string location,uint256 timestamp,string memo"
// Step 2 — Create the LP payload
final lpPayload = LPPayload(
lpVersion: '1.0.0',
srs: 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
locationType: 'geojson-point',
location: {'type': 'Point', 'coordinates': [-103.771556, 44.967243]},
);
print('Location type: ${lpPayload.locationType}');
print('Location: ${lpPayload.location}');
// Step 3 — Sign the attestation offchain
// A test private key — NEVER use a real key in source code
const testPrivateKey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const chainId = 11155111; // Sepolia
final easAddress = ChainConfig.forChainId(chainId)!.eas;
final signer = OffchainSigner.fromPrivateKey(
privateKeyHex: testPrivateKey,
chainId: chainId,
easContractAddress: easAddress,
);
final signed = await signer.signOffchainAttestation(
schema: schema,
lpPayload: lpPayload,
userData: {
'timestamp': BigInt.from(DateTime.now().millisecondsSinceEpoch ~/ 1000),
'memo': 'Tutorial checkpoint',
},
);
print('Attestation UID: ${signed.uid}');
print('Signer: ${signed.signer}');
// Step 4 — Verify the attestation
final result = signer.verifyOffchainAttestation(signed);
print('Valid: ${result.isValid}');
print('Recovered signer: ${result.recoveredAddress}');
assert(result.isValid, 'Signature verification failed');
assert(
result.recoveredAddress.toLowerCase() == signer.signerAddress.toLowerCase(),
'Recovered address mismatch',
);
// Step 5 — Inspect the signed attestation
print('Schema UID: ${signed.schemaUID}');
print('Version: ${signed.version}');
print('Revocable: ${signed.revocable}');
print('r: ${signed.signature.r}');
print('s: ${signed.signature.s}');
print('v: ${signed.signature.v}');
}Your SignedOffchainAttestation is ready to share, store, or anchor on-chain. Here are your next steps: