Notarize AT Protocol records on Ethereum using EAS (experiment)
at main 313 lines 10 kB view raw
1import { AtpAgent } from '@atproto/api'; 2import { ethers } from 'ethers'; 3import EASPackage from '@ethereum-attestation-service/eas-sdk'; 4const { EAS, SchemaEncoder, SchemaRegistry, NO_EXPIRATION } = EASPackage; 5 6import type { NotaryConfig, NotarizationResult, AttestationData } from './types.ts'; 7import { parseRecordURI, hashContent, getExplorerURL } from './utils.js'; 8 9// Default schemas (deployed by atnotary maintainers) 10const DEFAULT_SCHEMAS = { 11 'sepolia': '0x2a39517604107c79acbb962fe809795a87b7e47b8682fd9fbd3f62694fcca47c', 12 'base-sepolia': '0x2a39517604107c79acbb962fe809795a87b7e47b8682fd9fbd3f62694fcca47c', 13 'base': '0x...', // TODO: Deploy and add schema UID 14}; 15 16// Chain configurations 17const CHAIN_CONFIG = { 18 'sepolia': { 19 rpcUrl: 'https://1rpc.io/sepolia', 20 easContractAddress: '0xC2679fBD37d54388Ce493F1DB75320D236e1815e', 21 schemaRegistryAddress: '0x0a7E2Ff54e76B8E6659aedc9103FB21c038050D0', 22 }, 23 'base-sepolia': { 24 rpcUrl: 'https://sepolia.base.org', 25 easContractAddress: '0x4200000000000000000000000000000000000021', 26 schemaRegistryAddress: '0x4200000000000000000000000000000000000020', 27 }, 28 'base': { 29 rpcUrl: 'https://mainnet.base.org', 30 easContractAddress: '0x4200000000000000000000000000000000000021', 31 schemaRegistryAddress: '0x4200000000000000000000000000000000000020', 32 } 33}; 34 35const SCHEMA_STRING = "string recordURI,string cid,bytes32 contentHash,string pds,uint256 timestamp"; 36 37export class ATProtocolNotary { 38 private config: Required<NotaryConfig>; 39 private provider: ethers.JsonRpcProvider; 40 private signer?: ethers.Wallet; 41 private network: string; 42 private chainConfig: typeof CHAIN_CONFIG[keyof typeof CHAIN_CONFIG]; 43 private eas: any; 44 private schemaRegistry?: any; 45 46 constructor(config: NotaryConfig = {}, network: string = 'sepolia') { 47 this.network = network; 48 this.chainConfig = CHAIN_CONFIG[network as keyof typeof CHAIN_CONFIG] || CHAIN_CONFIG['sepolia']; 49 50 // Use default schema if not provided 51 const defaultSchemaUID = DEFAULT_SCHEMAS[network as keyof typeof DEFAULT_SCHEMAS]; 52 53 this.config = { 54 privateKey: config.privateKey || '', 55 rpcUrl: config.rpcUrl || this.chainConfig.rpcUrl, 56 easContractAddress: (config.easContractAddress || this.chainConfig.easContractAddress), 57 schemaRegistryAddress: (config.schemaRegistryAddress || this.chainConfig.schemaRegistryAddress), 58 schemaUID: config.schemaUID || defaultSchemaUID || '', 59 }; 60 61 // Create ethers provider (always needed for reading) 62 this.provider = new ethers.JsonRpcProvider(this.config.rpcUrl); 63 64 // Only create signer if private key provided (for writing operations) 65 if (this.config.privateKey) { 66 this.signer = new ethers.Wallet(this.config.privateKey, this.provider); 67 68 // Initialize EAS SDK for writing 69 this.eas = new EAS(this.config.easContractAddress); 70 this.eas.connect(this.signer); 71 72 this.schemaRegistry = new SchemaRegistry(this.config.schemaRegistryAddress); 73 this.schemaRegistry.connect(this.signer); 74 } else { 75 // For read-only operations, connect EAS to provider 76 this.eas = new EAS(this.config.easContractAddress); 77 this.eas.connect(this.provider as any); 78 } 79 } 80 81 82 /** 83 * Initialize: Create EAS schema (one-time setup) 84 */ 85 async initializeSchema(): Promise<string> { 86 if (!this.config.privateKey) { 87 throw new Error('Private key required for schema initialization'); 88 } 89 const transaction = await this.schemaRegistry.register({ 90 schema: SCHEMA_STRING, 91 resolverAddress: ethers.ZeroAddress, 92 revocable: true, 93 }); 94 95 await transaction.wait(); 96 97 // Return the transaction hash as schema UID placeholder 98 return transaction.receipt.hash; 99 } 100 101 /** 102 * Resolve DID to PDS endpoint 103 */ 104 async resolveDIDtoPDS(did: string): Promise<string> { 105 // For did:plc, resolve via PLC directory 106 if (did.startsWith('did:plc:')) { 107 const response = await fetch(`https://plc.directory/${did}`); 108 if (!response.ok) { 109 throw new Error(`Failed to resolve DID: ${did}`); 110 } 111 112 const didDocument: any = await response.json(); 113 114 // Find the PDS service endpoint 115 const pdsService = didDocument.service?.find( 116 (s: any) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' 117 ); 118 119 if (!pdsService?.serviceEndpoint) { 120 throw new Error(`No PDS endpoint found for DID: ${did}`); 121 } 122 123 return pdsService.serviceEndpoint; 124 } 125 126 // For did:web, resolve via web 127 if (did.startsWith('did:web:')) { 128 const domain = did.replace('did:web:', ''); 129 const response = await fetch(`https://${domain}/.well-known/did.json`); 130 if (!response.ok) { 131 throw new Error(`Failed to resolve DID: ${did}`); 132 } 133 134 const didDocument: any = await response.json(); 135 const pdsService = didDocument.service?.find( 136 (s: any) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' 137 ); 138 139 if (!pdsService?.serviceEndpoint) { 140 throw new Error(`No PDS endpoint found for DID: ${did}`); 141 } 142 143 return pdsService.serviceEndpoint; 144 } 145 146 throw new Error(`Unsupported DID method: ${did}`); 147 } 148 149 /** 150 * Fetch a record from AT Protocol (directly from PDS) 151 */ 152 async fetchRecord(recordURI: string): Promise<{ record: any; pds: string }> { 153 const { did, collection, rkey } = parseRecordURI(recordURI); 154 155 // Resolve DID to PDS 156 const pds = await this.resolveDIDtoPDS(did); 157 158 // Create agent pointing to user's PDS 159 const agent = new AtpAgent({ service: pds }); 160 161 const response = await agent.com.atproto.repo.getRecord({ 162 repo: did, 163 collection: collection, 164 rkey: rkey 165 }); 166 167 return { 168 record: response.data, 169 pds: pds 170 }; 171 } 172 173 /** 174 * Notarize an AT Protocol record on Ethereum 175 */ 176 async notarizeRecord(recordURI: string): Promise<NotarizationResult> { 177 if (!this.config.privateKey) { 178 throw new Error('Private key required for notarization'); 179 } 180 if (!this.config.schemaUID) { 181 throw new Error('Schema UID not set. Run initializeSchema() first.'); 182 } 183 184 // Parse URI 185 const { collection } = parseRecordURI(recordURI); 186 187 // Fetch record directly from PDS 188 const { record, pds } = await this.fetchRecord(recordURI); 189 190 // Generate content hash 191 const contentHash = hashContent(record.value); 192 193 // Get CID from the record response 194 const recordCID = record.cid; 195 196 // Initialize SchemaEncoder with the schema string 197 const schemaEncoder = new SchemaEncoder(SCHEMA_STRING); 198 const encodedData = schemaEncoder.encodeData([ 199 { name: "recordURI", value: recordURI, type: "string" }, 200 { name: "cid", value: recordCID, type: "string" }, 201 { name: "contentHash", value: contentHash, type: "bytes32" }, 202 { name: "pds", value: pds, type: "string" }, 203 { name: "timestamp", value: Math.floor(Date.now() / 1000), type: "uint256" } 204 ]); 205 206 const transaction = await this.eas.attest({ 207 schema: this.config.schemaUID, 208 data: { 209 recipient: ethers.ZeroAddress, 210 expirationTime: NO_EXPIRATION, 211 revocable: false, 212 data: encodedData, 213 }, 214 }); 215 216 const newAttestationUID = await transaction.wait(); 217 218 return { 219 attestationUID: newAttestationUID, 220 recordURI, 221 cid: recordCID, 222 contentHash, 223 pds, 224 lexicon: collection, 225 transactionHash: transaction.receipt.hash, 226 explorerURL: getExplorerURL(newAttestationUID, this.network), 227 }; 228 } 229 230 231 /** 232 * Verify an attestation 233 */ 234 async verifyAttestation(attestationUID: string): Promise<AttestationData> { 235 const attestation = await this.eas.getAttestation(attestationUID); 236 237 if (!attestation || attestation.uid === '0x0000000000000000000000000000000000000000000000000000000000000000') { 238 throw new Error('Attestation not found'); 239 } 240 241 // Decode attestation data 242 const schemaEncoder = new SchemaEncoder(SCHEMA_STRING); 243 const decodedData = schemaEncoder.decodeData(attestation.data); 244 245 const recordURI = decodedData.find(d => d.name === 'recordURI')?.value.value as string; 246 const cid = decodedData.find(d => d.name === 'cid')?.value.value as string; 247 const contentHash = decodedData.find(d => d.name === 'contentHash')?.value.value as string; 248 const pds = decodedData.find(d => d.name === 'pds')?.value.value as string; 249 const timestamp = Number(decodedData.find(d => d.name === 'timestamp')?.value.value); 250 251 // Parse lexicon from recordURI (since it's not in schema) 252 const { collection: lexicon } = parseRecordURI(recordURI); 253 254 return { 255 uid: attestationUID, 256 recordURI, 257 cid, 258 contentHash, 259 pds, 260 lexicon, 261 timestamp, 262 attester: attestation.attester, 263 revoked: attestation.revocationTime > 0n, 264 explorerURL: getExplorerURL(attestationUID, this.network), 265 }; 266 } 267 268 269 /** 270 * Compare attestation with current record state 271 */ 272 async compareWithCurrent(attestationData: AttestationData): Promise<{ 273 exists: boolean; 274 cidMatches: boolean; 275 hashMatches: boolean; 276 currentCid?: string; 277 currentHash?: string; 278 }> { 279 try { 280 // fetchRecord now returns { record, pds } 281 const { record } = await this.fetchRecord(attestationData.recordURI); 282 const currentHash = hashContent(record.value); 283 const currentCid = record.cid; 284 285 return { 286 exists: true, 287 cidMatches: currentCid === attestationData.cid, 288 hashMatches: currentHash === attestationData.contentHash, 289 currentCid, 290 currentHash, 291 }; 292 } catch (error: any) { 293 if (error.message?.includes('RecordNotFound')) { 294 return { 295 exists: false, 296 cidMatches: false, 297 hashMatches: false 298 }; 299 } 300 throw error; 301 } 302 } 303 304 /** 305 * Get signer address 306 */ 307 async getAddress(): Promise<string> { 308 if (!this.signer) { 309 throw new Error('Private key required to get address'); 310 } 311 return this.signer.getAddress(); 312 } 313}