import { AtpAgent } from '@atproto/api'; import { ethers } from 'ethers'; import EASPackage from '@ethereum-attestation-service/eas-sdk'; const { EAS, SchemaEncoder, SchemaRegistry, NO_EXPIRATION } = EASPackage; import type { NotaryConfig, NotarizationResult, AttestationData } from './types.ts'; import { parseRecordURI, hashContent, getExplorerURL } from './utils.js'; // Default schemas (deployed by atnotary maintainers) const DEFAULT_SCHEMAS = { 'sepolia': '0x2a39517604107c79acbb962fe809795a87b7e47b8682fd9fbd3f62694fcca47c', 'base-sepolia': '0x2a39517604107c79acbb962fe809795a87b7e47b8682fd9fbd3f62694fcca47c', 'base': '0x...', // TODO: Deploy and add schema UID }; // Chain configurations const CHAIN_CONFIG = { 'sepolia': { rpcUrl: 'https://1rpc.io/sepolia', easContractAddress: '0xC2679fBD37d54388Ce493F1DB75320D236e1815e', schemaRegistryAddress: '0x0a7E2Ff54e76B8E6659aedc9103FB21c038050D0', }, 'base-sepolia': { rpcUrl: 'https://sepolia.base.org', easContractAddress: '0x4200000000000000000000000000000000000021', schemaRegistryAddress: '0x4200000000000000000000000000000000000020', }, 'base': { rpcUrl: 'https://mainnet.base.org', easContractAddress: '0x4200000000000000000000000000000000000021', schemaRegistryAddress: '0x4200000000000000000000000000000000000020', } }; const SCHEMA_STRING = "string recordURI,string cid,bytes32 contentHash,string pds,uint256 timestamp"; export class ATProtocolNotary { private config: Required; private provider: ethers.JsonRpcProvider; private signer?: ethers.Wallet; private network: string; private chainConfig: typeof CHAIN_CONFIG[keyof typeof CHAIN_CONFIG]; private eas: any; private schemaRegistry?: any; constructor(config: NotaryConfig = {}, network: string = 'sepolia') { this.network = network; this.chainConfig = CHAIN_CONFIG[network as keyof typeof CHAIN_CONFIG] || CHAIN_CONFIG['sepolia']; // Use default schema if not provided const defaultSchemaUID = DEFAULT_SCHEMAS[network as keyof typeof DEFAULT_SCHEMAS]; this.config = { privateKey: config.privateKey || '', rpcUrl: config.rpcUrl || this.chainConfig.rpcUrl, easContractAddress: (config.easContractAddress || this.chainConfig.easContractAddress), schemaRegistryAddress: (config.schemaRegistryAddress || this.chainConfig.schemaRegistryAddress), schemaUID: config.schemaUID || defaultSchemaUID || '', }; // Create ethers provider (always needed for reading) this.provider = new ethers.JsonRpcProvider(this.config.rpcUrl); // Only create signer if private key provided (for writing operations) if (this.config.privateKey) { this.signer = new ethers.Wallet(this.config.privateKey, this.provider); // Initialize EAS SDK for writing this.eas = new EAS(this.config.easContractAddress); this.eas.connect(this.signer); this.schemaRegistry = new SchemaRegistry(this.config.schemaRegistryAddress); this.schemaRegistry.connect(this.signer); } else { // For read-only operations, connect EAS to provider this.eas = new EAS(this.config.easContractAddress); this.eas.connect(this.provider as any); } } /** * Initialize: Create EAS schema (one-time setup) */ async initializeSchema(): Promise { if (!this.config.privateKey) { throw new Error('Private key required for schema initialization'); } const transaction = await this.schemaRegistry.register({ schema: SCHEMA_STRING, resolverAddress: ethers.ZeroAddress, revocable: true, }); await transaction.wait(); // Return the transaction hash as schema UID placeholder return transaction.receipt.hash; } /** * Resolve DID to PDS endpoint */ async resolveDIDtoPDS(did: string): Promise { // For did:plc, resolve via PLC directory if (did.startsWith('did:plc:')) { const response = await fetch(`https://plc.directory/${did}`); if (!response.ok) { throw new Error(`Failed to resolve DID: ${did}`); } const didDocument: any = await response.json(); // Find the PDS service endpoint const pdsService = didDocument.service?.find( (s: any) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' ); if (!pdsService?.serviceEndpoint) { throw new Error(`No PDS endpoint found for DID: ${did}`); } return pdsService.serviceEndpoint; } // For did:web, resolve via web if (did.startsWith('did:web:')) { const domain = did.replace('did:web:', ''); const response = await fetch(`https://${domain}/.well-known/did.json`); if (!response.ok) { throw new Error(`Failed to resolve DID: ${did}`); } const didDocument: any = await response.json(); const pdsService = didDocument.service?.find( (s: any) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' ); if (!pdsService?.serviceEndpoint) { throw new Error(`No PDS endpoint found for DID: ${did}`); } return pdsService.serviceEndpoint; } throw new Error(`Unsupported DID method: ${did}`); } /** * Fetch a record from AT Protocol (directly from PDS) */ async fetchRecord(recordURI: string): Promise<{ record: any; pds: string }> { const { did, collection, rkey } = parseRecordURI(recordURI); // Resolve DID to PDS const pds = await this.resolveDIDtoPDS(did); // Create agent pointing to user's PDS const agent = new AtpAgent({ service: pds }); const response = await agent.com.atproto.repo.getRecord({ repo: did, collection: collection, rkey: rkey }); return { record: response.data, pds: pds }; } /** * Notarize an AT Protocol record on Ethereum */ async notarizeRecord(recordURI: string): Promise { if (!this.config.privateKey) { throw new Error('Private key required for notarization'); } if (!this.config.schemaUID) { throw new Error('Schema UID not set. Run initializeSchema() first.'); } // Parse URI const { collection } = parseRecordURI(recordURI); // Fetch record directly from PDS const { record, pds } = await this.fetchRecord(recordURI); // Generate content hash const contentHash = hashContent(record.value); // Get CID from the record response const recordCID = record.cid; // Initialize SchemaEncoder with the schema string const schemaEncoder = new SchemaEncoder(SCHEMA_STRING); const encodedData = schemaEncoder.encodeData([ { name: "recordURI", value: recordURI, type: "string" }, { name: "cid", value: recordCID, type: "string" }, { name: "contentHash", value: contentHash, type: "bytes32" }, { name: "pds", value: pds, type: "string" }, { name: "timestamp", value: Math.floor(Date.now() / 1000), type: "uint256" } ]); const transaction = await this.eas.attest({ schema: this.config.schemaUID, data: { recipient: ethers.ZeroAddress, expirationTime: NO_EXPIRATION, revocable: false, data: encodedData, }, }); const newAttestationUID = await transaction.wait(); return { attestationUID: newAttestationUID, recordURI, cid: recordCID, contentHash, pds, lexicon: collection, transactionHash: transaction.receipt.hash, explorerURL: getExplorerURL(newAttestationUID, this.network), }; } /** * Verify an attestation */ async verifyAttestation(attestationUID: string): Promise { const attestation = await this.eas.getAttestation(attestationUID); if (!attestation || attestation.uid === '0x0000000000000000000000000000000000000000000000000000000000000000') { throw new Error('Attestation not found'); } // Decode attestation data const schemaEncoder = new SchemaEncoder(SCHEMA_STRING); const decodedData = schemaEncoder.decodeData(attestation.data); const recordURI = decodedData.find(d => d.name === 'recordURI')?.value.value as string; const cid = decodedData.find(d => d.name === 'cid')?.value.value as string; const contentHash = decodedData.find(d => d.name === 'contentHash')?.value.value as string; const pds = decodedData.find(d => d.name === 'pds')?.value.value as string; const timestamp = Number(decodedData.find(d => d.name === 'timestamp')?.value.value); // Parse lexicon from recordURI (since it's not in schema) const { collection: lexicon } = parseRecordURI(recordURI); return { uid: attestationUID, recordURI, cid, contentHash, pds, lexicon, timestamp, attester: attestation.attester, revoked: attestation.revocationTime > 0n, explorerURL: getExplorerURL(attestationUID, this.network), }; } /** * Compare attestation with current record state */ async compareWithCurrent(attestationData: AttestationData): Promise<{ exists: boolean; cidMatches: boolean; hashMatches: boolean; currentCid?: string; currentHash?: string; }> { try { // fetchRecord now returns { record, pds } const { record } = await this.fetchRecord(attestationData.recordURI); const currentHash = hashContent(record.value); const currentCid = record.cid; return { exists: true, cidMatches: currentCid === attestationData.cid, hashMatches: currentHash === attestationData.contentHash, currentCid, currentHash, }; } catch (error: any) { if (error.message?.includes('RecordNotFound')) { return { exists: false, cidMatches: false, hashMatches: false }; } throw error; } } /** * Get signer address */ async getAddress(): Promise { if (!this.signer) { throw new Error('Private key required to get address'); } return this.signer.getAddress(); } }