Notarize AT Protocol records on Ethereum using EAS (experiment)

updates

Changed files
+186 -70
src
tests
integration
unit
+17 -13
README.md
··· 42 43 ## Setup 44 45 - 1. Create `.env` in your working directory: 46 ```bash 47 PRIVATE_KEY="0x..." 48 - SCHEMA_UID="" 49 ``` 50 51 - 2. Initialize (one-time): 52 ```bash 53 atnotary init --network sepolia 54 ``` 55 56 - Add the outputted `SCHEMA_UID` to `.env`. 57 - 58 - 3. Get testnet ETH from [Sepolia faucet](https://sepoliafaucet.com/) 59 60 ## Usage 61 62 - **Notarize:** 63 ```bash 64 atnotary notarize "at://did:plc:xxx/app.bsky.feed.post/abc" 65 - ``` 66 67 - **Verify:** 68 - ```bash 69 atnotary verify "0xabc..." --network sepolia 70 - ``` 71 72 - **Compare with current state:** 73 - ```bash 74 atnotary verify "0xabc..." --compare 75 ``` 76
··· 42 43 ## Setup 44 45 + 1. **Create `.env` file:** 46 + 47 ```bash 48 PRIVATE_KEY="0x..." 49 + # SCHEMA_UID is optional - default schemas are provided 50 ``` 51 52 + 2. **Get testnet ETH:** 53 + - Sepolia: https://sepoliafaucet.com/ 54 + - Base Sepolia: https://bridge.base.org/ 55 + 56 + **That's it!** Default schemas are provided for all networks. 57 + 58 + ### Custom Schema (Optional) 59 + 60 + If you want to deploy your own schema: 61 + 62 ```bash 63 atnotary init --network sepolia 64 ``` 65 66 + Then add the `SCHEMA_UID` to your `.env` file. 67 68 ## Usage 69 70 ```bash 71 + # notarize 72 atnotary notarize "at://did:plc:xxx/app.bsky.feed.post/abc" 73 74 + # verify 75 atnotary verify "0xabc..." --network sepolia 76 77 + # compare with current state 78 atnotary verify "0xabc..." --compare 79 ``` 80
+25 -7
src/cli.ts
··· 114 const spinner = ora(`Fetching attestation from EAS (${options.network})...`).start(); 115 116 const notary = new ATProtocolNotary({ 117 - privateKey: process.env.PRIVATE_KEY!, 118 schemaUID: process.env.SCHEMA_UID, 119 }, options.network); 120 ··· 140 141 if (!comparison.exists) { 142 console.log(chalk.red('⚠ Record has been deleted')); 143 - } else if (comparison.matches) { 144 - console.log(chalk.green('✓ Content matches attestation (unchanged)')); 145 } else { 146 - console.log(chalk.yellow('⚠ Content has changed since attestation')); 147 - console.log(chalk.gray(` Attested CID: ${attestation.cid}`)); 148 - console.log(chalk.gray(` Attested Hash: ${attestation.contentHash.substring(0, 20)}...`)); 149 - console.log(chalk.gray(` Current Hash: ${comparison.currentHash!.substring(0, 20)}...`)); 150 } 151 } catch (err: any) { 152 console.log(chalk.red(`⚠ Could not fetch current record: ${err.message}`)); 153 } 154 } 155 156 console.log(chalk.blue('\n📋 View on explorer:')); 157 console.log(chalk.cyan(attestation.explorerURL + '\n'));
··· 114 const spinner = ora(`Fetching attestation from EAS (${options.network})...`).start(); 115 116 const notary = new ATProtocolNotary({ 117 schemaUID: process.env.SCHEMA_UID, 118 }, options.network); 119 ··· 139 140 if (!comparison.exists) { 141 console.log(chalk.red('⚠ Record has been deleted')); 142 } else { 143 + // Check CID 144 + if (comparison.cidMatches) { 145 + console.log(chalk.green('✓ CID matches (content identical via AT Protocol)')); 146 + } else { 147 + console.log(chalk.red('✗ CID changed (content modified)')); 148 + console.log(chalk.gray(` Attested CID: ${attestation.cid}`)); 149 + console.log(chalk.gray(` Current CID: ${comparison.currentCid}`)); 150 + } 151 + 152 + // Check content hash 153 + if (comparison.hashMatches) { 154 + console.log(chalk.green('✓ Content hash matches')); 155 + } else { 156 + console.log(chalk.red('✗ Content hash changed')); 157 + console.log(chalk.gray(` Attested Hash: ${attestation.contentHash.substring(0, 20)}...`)); 158 + console.log(chalk.gray(` Current Hash: ${comparison.currentHash!.substring(0, 20)}...`)); 159 + } 160 + 161 + // Summary 162 + if (comparison.cidMatches && comparison.hashMatches) { 163 + console.log(chalk.green('\n✅ Record unchanged since attestation')); 164 + } else { 165 + console.log(chalk.yellow('\n⚠️ Record has been modified since attestation')); 166 + } 167 } 168 } catch (err: any) { 169 console.log(chalk.red(`⚠ Could not fetch current record: ${err.message}`)); 170 } 171 } 172 + 173 174 console.log(chalk.blue('\n📋 View on explorer:')); 175 console.log(chalk.cyan(attestation.explorerURL + '\n'));
+55 -21
src/lib/notary.ts
··· 6 import type { NotaryConfig, NotarizationResult, AttestationData } from './types'; 7 import { parseRecordURI, hashContent, getExplorerURL } from './utils'; 8 9 // Chain configurations 10 const CHAIN_CONFIG = { 11 'sepolia': { ··· 36 private eas: any; 37 private schemaRegistry: any; 38 39 - constructor(config: NotaryConfig, network: string = 'sepolia') { 40 this.network = network; 41 this.chainConfig = CHAIN_CONFIG[network as keyof typeof CHAIN_CONFIG] || CHAIN_CONFIG['sepolia']; 42 43 this.config = { 44 - privateKey: config.privateKey, 45 rpcUrl: config.rpcUrl || this.chainConfig.rpcUrl, 46 - easContractAddress: config.easContractAddress || this.chainConfig.easContractAddress, 47 - schemaRegistryAddress: config.schemaRegistryAddress || this.chainConfig.schemaRegistryAddress, 48 - schemaUID: config.schemaUID || '', 49 }; 50 51 - if (!this.config.privateKey) { 52 - throw new Error('Private key is required'); 53 - } 54 - 55 - // Create ethers provider and signer 56 this.provider = new ethers.JsonRpcProvider(this.config.rpcUrl); 57 - this.signer = new ethers.Wallet(this.config.privateKey, this.provider); 58 - 59 - // Initialize EAS SDK 60 - this.eas = new EAS(this.config.easContractAddress); 61 - this.eas.connect(this.signer); 62 63 - this.schemaRegistry = new SchemaRegistry(this.config.schemaRegistryAddress); 64 - this.schemaRegistry.connect(this.signer); 65 } 66 67 /** 68 * Initialize: Create EAS schema (one-time setup) 69 */ 70 async initializeSchema(): Promise<string> { 71 const transaction = await this.schemaRegistry.register({ 72 schema: SCHEMA_STRING, 73 resolverAddress: ethers.ZeroAddress, ··· 156 * Notarize an AT Protocol record on Ethereum 157 */ 158 async notarizeRecord(recordURI: string): Promise<NotarizationResult> { 159 if (!this.config.schemaUID) { 160 throw new Error('Schema UID not set. Run initializeSchema() first.'); 161 } ··· 250 */ 251 async compareWithCurrent(attestationData: AttestationData): Promise<{ 252 exists: boolean; 253 - matches: boolean; 254 currentHash?: string; 255 }> { 256 try { 257 - // fetchRecord now returns { record, pds }, so we need to destructure 258 const { record } = await this.fetchRecord(attestationData.recordURI); 259 const currentHash = hashContent(record.value); 260 261 return { 262 exists: true, 263 - matches: currentHash === attestationData.contentHash, 264 currentHash, 265 }; 266 } catch (error: any) { 267 if (error.message?.includes('RecordNotFound')) { 268 - return { exists: false, matches: false }; 269 } 270 throw error; 271 } ··· 275 * Get signer address 276 */ 277 async getAddress(): Promise<string> { 278 return this.signer.getAddress(); 279 } 280 }
··· 6 import type { NotaryConfig, NotarizationResult, AttestationData } from './types'; 7 import { parseRecordURI, hashContent, getExplorerURL } from './utils'; 8 9 + // Default schemas (deployed by atnotary maintainers) 10 + const DEFAULT_SCHEMAS = { 11 + 'sepolia': '0x2a39517604107c79acbb962fe809795a87b7e47b8682fd9fbd3f62694fcca47c', 12 + 'base-sepolia': '0x...', // TODO: Deploy and add schema UID 13 + 'base': '0x...', // TODO: Deploy and add schema UID 14 + }; 15 + 16 // Chain configurations 17 const CHAIN_CONFIG = { 18 'sepolia': { ··· 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, ··· 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 } ··· 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 + console.log(record.value) 283 const currentHash = hashContent(record.value); 284 + const currentCid = record.cid; 285 286 return { 287 exists: true, 288 + cidMatches: currentCid === attestationData.cid, 289 + hashMatches: currentHash === attestationData.contentHash, 290 + currentCid, 291 currentHash, 292 }; 293 } catch (error: any) { 294 if (error.message?.includes('RecordNotFound')) { 295 + return { 296 + exists: false, 297 + cidMatches: false, 298 + hashMatches: false 299 + }; 300 } 301 throw error; 302 } ··· 306 * Get signer address 307 */ 308 async getAddress(): Promise<string> { 309 + if (!this.signer) { 310 + throw new Error('Private key required to get address'); 311 + } 312 return this.signer.getAddress(); 313 } 314 }
+1 -1
src/lib/types.ts
··· 1 export interface NotaryConfig { 2 - privateKey: string; 3 rpcUrl?: string; 4 easContractAddress?: string; 5 schemaRegistryAddress?: string;
··· 1 export interface NotaryConfig { 2 + privateKey?: string; 3 rpcUrl?: string; 4 easContractAddress?: string; 5 schemaRegistryAddress?: string;
+19 -1
src/lib/utils.ts
··· 13 }; 14 } 15 16 export function hashContent(content: any): string { 17 - const jsonString = JSON.stringify(content, null, 0); 18 return '0x' + crypto.createHash('sha256').update(jsonString).digest('hex'); 19 } 20
··· 13 }; 14 } 15 16 + // Recursively sort object keys for deterministic hashing 17 + function sortObject(obj: any): any { 18 + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { 19 + return obj; 20 + } 21 + 22 + const sorted: any = {}; 23 + Object.keys(obj) 24 + .sort() 25 + .forEach(key => { 26 + sorted[key] = sortObject(obj[key]); 27 + }); 28 + 29 + return sorted; 30 + } 31 + 32 export function hashContent(content: any): string { 33 + // Sort keys for deterministic hashing 34 + const sortedContent = sortObject(content); 35 + const jsonString = JSON.stringify(sortedContent); 36 return '0x' + crypto.createHash('sha256').update(jsonString).digest('hex'); 37 } 38
+38 -27
tests/integration/notary.test.ts
··· 60 decodeData: vi.fn().mockReturnValue([ 61 { name: 'recordURI', value: { value: 'at://did:plc:test/app.bsky.feed.post/123' } }, 62 { name: 'cid', value: { value: 'bafyreiabc123' } }, 63 - { name: 'contentHash', value: { value: '0xhash123' } }, 64 { name: 'pds', value: { value: 'https://pds.example.com' } }, 65 { name: 'timestamp', value: { value: 1234567890 } }, 66 ]), ··· 101 expect(notary).toBeInstanceOf(ATProtocolNotary); 102 expect(notary.getAddress()).toBeTruthy(); 103 }); 104 - 105 - it('should throw error without private key', () => { 106 - expect(() => { 107 - new ATProtocolNotary({ 108 - privateKey: '', 109 - }); 110 - }).toThrow('Private key is required'); 111 - }); 112 }); 113 114 describe('resolveDIDtoPDS', () => { 115 it('should resolve did:plc to PDS', async () => { 116 const notary = new ATProtocolNotary({ 117 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 118 schemaUID: '0xschemauid', 119 }); 120 ··· 126 127 it('should throw error for unsupported DID method', async () => { 128 const notary = new ATProtocolNotary({ 129 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 130 schemaUID: '0xschemauid', 131 }); 132 ··· 142 }); 143 144 const notary = new ATProtocolNotary({ 145 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 146 schemaUID: '0xschemauid', 147 }); 148 ··· 155 describe('fetchRecord', () => { 156 it('should fetch record from PDS', async () => { 157 const notary = new ATProtocolNotary({ 158 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 159 schemaUID: '0xschemauid', 160 }); 161 ··· 182 expect(result.pds).toBe('https://pds.example.com'); 183 expect(result.transactionHash).toBeTruthy(); 184 }); 185 - 186 - it('should throw error without schema UID', async () => { 187 - const notary = new ATProtocolNotary({ 188 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 189 - }); 190 - 191 - await expect( 192 - notary.notarizeRecord('at://did:plc:test/app.bsky.feed.post/123') 193 - ).rejects.toThrow('Schema UID not set'); 194 - }); 195 }); 196 197 describe('verifyAttestation', () => { 198 it('should verify attestation successfully', async () => { 199 - const notary = new ATProtocolNotary({ 200 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 201 schemaUID: '0xschemauid', 202 }); 203 ··· 214 215 describe('compareWithCurrent', () => { 216 it('should detect unchanged content', async () => { 217 const notary = new ATProtocolNotary({ 218 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 219 schemaUID: '0xschemauid', 220 }); 221 ··· 223 const comparison = await notary.compareWithCurrent(attestation); 224 225 expect(comparison.exists).toBe(true); 226 - expect(comparison.currentHash).toBeTruthy(); 227 }); 228 }); 229 });
··· 60 decodeData: vi.fn().mockReturnValue([ 61 { name: 'recordURI', value: { value: 'at://did:plc:test/app.bsky.feed.post/123' } }, 62 { name: 'cid', value: { value: 'bafyreiabc123' } }, 63 + { name: 'contentHash', value: { value: '0xa64b286ffd5cc55c57f4e9c74d1122aa081dc5f662648cb5cc5ced74e0e12cd5' } }, 64 { name: 'pds', value: { value: 'https://pds.example.com' } }, 65 { name: 'timestamp', value: { value: 1234567890 } }, 66 ]), ··· 101 expect(notary).toBeInstanceOf(ATProtocolNotary); 102 expect(notary.getAddress()).toBeTruthy(); 103 }); 104 }); 105 106 describe('resolveDIDtoPDS', () => { 107 it('should resolve did:plc to PDS', async () => { 108 const notary = new ATProtocolNotary({ 109 schemaUID: '0xschemauid', 110 }); 111 ··· 117 118 it('should throw error for unsupported DID method', async () => { 119 const notary = new ATProtocolNotary({ 120 schemaUID: '0xschemauid', 121 }); 122 ··· 132 }); 133 134 const notary = new ATProtocolNotary({ 135 schemaUID: '0xschemauid', 136 }); 137 ··· 144 describe('fetchRecord', () => { 145 it('should fetch record from PDS', async () => { 146 const notary = new ATProtocolNotary({ 147 schemaUID: '0xschemauid', 148 }); 149 ··· 170 expect(result.pds).toBe('https://pds.example.com'); 171 expect(result.transactionHash).toBeTruthy(); 172 }); 173 }); 174 175 describe('verifyAttestation', () => { 176 it('should verify attestation successfully', async () => { 177 + const notary = new ATProtocolNotary({ 178 schemaUID: '0xschemauid', 179 }); 180 ··· 191 192 describe('compareWithCurrent', () => { 193 it('should detect unchanged content', async () => { 194 + const notary = new ATProtocolNotary({ 195 + schemaUID: '0xschemauid', 196 + }); 197 + 198 + const attestation = await notary.verifyAttestation('0xattestationuid123'); 199 + const comparison = await notary.compareWithCurrent(attestation); 200 + console.log({ attestation, comparison }) 201 + 202 + expect(comparison.exists).toBe(true); 203 + expect(comparison.cidMatches).toBe(true); 204 + expect(comparison.hashMatches).toBe(true); 205 + expect(comparison.currentCid).toBeTruthy(); 206 + expect(comparison.currentHash).toBeTruthy(); 207 + }); 208 + 209 + it('should detect changed content', async () => { 210 + // Mock different CID and hash 211 + const { AtpAgent } = await import('@atproto/api'); 212 + (AtpAgent as any).mockImplementation(() => ({ 213 + com: { 214 + atproto: { 215 + repo: { 216 + getRecord: vi.fn().mockResolvedValue({ 217 + data: { 218 + value: { text: 'Modified post', createdAt: '2024-01-02' }, 219 + cid: 'bafyreidifferent123', 220 + }, 221 + }), 222 + }, 223 + }, 224 + }, 225 + })); 226 + 227 const notary = new ATProtocolNotary({ 228 schemaUID: '0xschemauid', 229 }); 230 ··· 232 const comparison = await notary.compareWithCurrent(attestation); 233 234 expect(comparison.exists).toBe(true); 235 + expect(comparison.cidMatches).toBe(false); 236 + expect(comparison.hashMatches).toBe(false); 237 }); 238 }); 239 + 240 });
+31
tests/unit/utils.test.ts
··· 79 expect(url).toBe('https://sepolia.easscan.org/attestation/view/0xabc123'); 80 }); 81 });
··· 79 expect(url).toBe('https://sepolia.easscan.org/attestation/view/0xabc123'); 80 }); 81 }); 82 + 83 + describe('hashContent', () => { 84 + it('should generate consistent SHA-256 hash', () => { 85 + const content = { text: 'Hello World', createdAt: '2024-01-01' }; 86 + const hash1 = hashContent(content); 87 + const hash2 = hashContent(content); 88 + 89 + expect(hash1).toBe(hash2); 90 + expect(hash1).toMatch(/^0x[a-f0-9]{64}$/); 91 + }); 92 + 93 + it('should be order-independent', () => { 94 + const content1 = { a: 1, b: 2, c: 3 }; 95 + const content2 = { c: 3, a: 1, b: 2 }; 96 + const content3 = { b: 2, c: 3, a: 1 }; 97 + 98 + const hash1 = hashContent(content1); 99 + const hash2 = hashContent(content2); 100 + const hash3 = hashContent(content3); 101 + 102 + expect(hash1).toBe(hash2); 103 + expect(hash2).toBe(hash3); 104 + }); 105 + 106 + it('should handle nested objects', () => { 107 + const content1 = { outer: { b: 2, a: 1 }, x: 5 }; 108 + const content2 = { x: 5, outer: { a: 1, b: 2 } }; 109 + 110 + expect(hashContent(content1)).toBe(hashContent(content2)); 111 + }); 112 + });