Notarize AT Protocol records on Ethereum using EAS (experiment)
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}