Notarize AT Protocol records on Ethereum using EAS (experiment)

updates

Changed files
+186 -70
src
tests
integration
unit
+17 -13
README.md
··· 42 42 43 43 ## Setup 44 44 45 - 1. Create `.env` in your working directory: 45 + 1. **Create `.env` file:** 46 + 46 47 ```bash 47 48 PRIVATE_KEY="0x..." 48 - SCHEMA_UID="" 49 + # SCHEMA_UID is optional - default schemas are provided 49 50 ``` 50 51 51 - 2. Initialize (one-time): 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 + 52 62 ```bash 53 63 atnotary init --network sepolia 54 64 ``` 55 65 56 - Add the outputted `SCHEMA_UID` to `.env`. 57 - 58 - 3. Get testnet ETH from [Sepolia faucet](https://sepoliafaucet.com/) 66 + Then add the `SCHEMA_UID` to your `.env` file. 59 67 60 68 ## Usage 61 69 62 - **Notarize:** 63 70 ```bash 71 + # notarize 64 72 atnotary notarize "at://did:plc:xxx/app.bsky.feed.post/abc" 65 - ``` 66 73 67 - **Verify:** 68 - ```bash 74 + # verify 69 75 atnotary verify "0xabc..." --network sepolia 70 - ``` 71 76 72 - **Compare with current state:** 73 - ```bash 77 + # compare with current state 74 78 atnotary verify "0xabc..." --compare 75 79 ``` 76 80
+25 -7
src/cli.ts
··· 114 114 const spinner = ora(`Fetching attestation from EAS (${options.network})...`).start(); 115 115 116 116 const notary = new ATProtocolNotary({ 117 - privateKey: process.env.PRIVATE_KEY!, 118 117 schemaUID: process.env.SCHEMA_UID, 119 118 }, options.network); 120 119 ··· 140 139 141 140 if (!comparison.exists) { 142 141 console.log(chalk.red('⚠ Record has been deleted')); 143 - } else if (comparison.matches) { 144 - console.log(chalk.green('✓ Content matches attestation (unchanged)')); 145 142 } 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)}...`)); 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 + } 150 167 } 151 168 } catch (err: any) { 152 169 console.log(chalk.red(`⚠ Could not fetch current record: ${err.message}`)); 153 170 } 154 171 } 172 + 155 173 156 174 console.log(chalk.blue('\n📋 View on explorer:')); 157 175 console.log(chalk.cyan(attestation.explorerURL + '\n'));
+55 -21
src/lib/notary.ts
··· 6 6 import type { NotaryConfig, NotarizationResult, AttestationData } from './types'; 7 7 import { parseRecordURI, hashContent, getExplorerURL } from './utils'; 8 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 + 9 16 // Chain configurations 10 17 const CHAIN_CONFIG = { 11 18 'sepolia': { ··· 36 43 private eas: any; 37 44 private schemaRegistry: any; 38 45 39 - constructor(config: NotaryConfig, network: string = 'sepolia') { 46 + constructor(config: NotaryConfig = {}, network: string = 'sepolia') { 40 47 this.network = network; 41 48 this.chainConfig = CHAIN_CONFIG[network as keyof typeof CHAIN_CONFIG] || CHAIN_CONFIG['sepolia']; 42 49 50 + // Use default schema if not provided 51 + const defaultSchemaUID = DEFAULT_SCHEMAS[network as keyof typeof DEFAULT_SCHEMAS]; 52 + 43 53 this.config = { 44 - privateKey: config.privateKey, 54 + privateKey: config.privateKey || '', 45 55 rpcUrl: config.rpcUrl || this.chainConfig.rpcUrl, 46 - easContractAddress: config.easContractAddress || this.chainConfig.easContractAddress, 47 - schemaRegistryAddress: config.schemaRegistryAddress || this.chainConfig.schemaRegistryAddress, 48 - schemaUID: config.schemaUID || '', 56 + easContractAddress: (config.easContractAddress || this.chainConfig.easContractAddress), 57 + schemaRegistryAddress: (config.schemaRegistryAddress || this.chainConfig.schemaRegistryAddress), 58 + schemaUID: config.schemaUID || defaultSchemaUID || '', 49 59 }; 50 60 51 - if (!this.config.privateKey) { 52 - throw new Error('Private key is required'); 53 - } 54 - 55 - // Create ethers provider and signer 61 + // Create ethers provider (always needed for reading) 56 62 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); 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); 62 71 63 - this.schemaRegistry = new SchemaRegistry(this.config.schemaRegistryAddress); 64 - this.schemaRegistry.connect(this.signer); 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 + } 65 79 } 66 80 81 + 67 82 /** 68 83 * Initialize: Create EAS schema (one-time setup) 69 84 */ 70 85 async initializeSchema(): Promise<string> { 86 + if (!this.config.privateKey) { 87 + throw new Error('Private key required for schema initialization'); 88 + } 71 89 const transaction = await this.schemaRegistry.register({ 72 90 schema: SCHEMA_STRING, 73 91 resolverAddress: ethers.ZeroAddress, ··· 156 174 * Notarize an AT Protocol record on Ethereum 157 175 */ 158 176 async notarizeRecord(recordURI: string): Promise<NotarizationResult> { 177 + if (!this.config.privateKey) { 178 + throw new Error('Private key required for notarization'); 179 + } 159 180 if (!this.config.schemaUID) { 160 181 throw new Error('Schema UID not set. Run initializeSchema() first.'); 161 182 } ··· 250 271 */ 251 272 async compareWithCurrent(attestationData: AttestationData): Promise<{ 252 273 exists: boolean; 253 - matches: boolean; 274 + cidMatches: boolean; 275 + hashMatches: boolean; 276 + currentCid?: string; 254 277 currentHash?: string; 255 278 }> { 256 279 try { 257 - // fetchRecord now returns { record, pds }, so we need to destructure 280 + // fetchRecord now returns { record, pds } 258 281 const { record } = await this.fetchRecord(attestationData.recordURI); 282 + console.log(record.value) 259 283 const currentHash = hashContent(record.value); 284 + const currentCid = record.cid; 260 285 261 286 return { 262 287 exists: true, 263 - matches: currentHash === attestationData.contentHash, 288 + cidMatches: currentCid === attestationData.cid, 289 + hashMatches: currentHash === attestationData.contentHash, 290 + currentCid, 264 291 currentHash, 265 292 }; 266 293 } catch (error: any) { 267 294 if (error.message?.includes('RecordNotFound')) { 268 - return { exists: false, matches: false }; 295 + return { 296 + exists: false, 297 + cidMatches: false, 298 + hashMatches: false 299 + }; 269 300 } 270 301 throw error; 271 302 } ··· 275 306 * Get signer address 276 307 */ 277 308 async getAddress(): Promise<string> { 309 + if (!this.signer) { 310 + throw new Error('Private key required to get address'); 311 + } 278 312 return this.signer.getAddress(); 279 313 } 280 314 }
+1 -1
src/lib/types.ts
··· 1 1 export interface NotaryConfig { 2 - privateKey: string; 2 + privateKey?: string; 3 3 rpcUrl?: string; 4 4 easContractAddress?: string; 5 5 schemaRegistryAddress?: string;
+19 -1
src/lib/utils.ts
··· 13 13 }; 14 14 } 15 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 + 16 32 export function hashContent(content: any): string { 17 - const jsonString = JSON.stringify(content, null, 0); 33 + // Sort keys for deterministic hashing 34 + const sortedContent = sortObject(content); 35 + const jsonString = JSON.stringify(sortedContent); 18 36 return '0x' + crypto.createHash('sha256').update(jsonString).digest('hex'); 19 37 } 20 38
+38 -27
tests/integration/notary.test.ts
··· 60 60 decodeData: vi.fn().mockReturnValue([ 61 61 { name: 'recordURI', value: { value: 'at://did:plc:test/app.bsky.feed.post/123' } }, 62 62 { name: 'cid', value: { value: 'bafyreiabc123' } }, 63 - { name: 'contentHash', value: { value: '0xhash123' } }, 63 + { name: 'contentHash', value: { value: '0xa64b286ffd5cc55c57f4e9c74d1122aa081dc5f662648cb5cc5ced74e0e12cd5' } }, 64 64 { name: 'pds', value: { value: 'https://pds.example.com' } }, 65 65 { name: 'timestamp', value: { value: 1234567890 } }, 66 66 ]), ··· 101 101 expect(notary).toBeInstanceOf(ATProtocolNotary); 102 102 expect(notary.getAddress()).toBeTruthy(); 103 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 104 }); 113 105 114 106 describe('resolveDIDtoPDS', () => { 115 107 it('should resolve did:plc to PDS', async () => { 116 108 const notary = new ATProtocolNotary({ 117 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 118 109 schemaUID: '0xschemauid', 119 110 }); 120 111 ··· 126 117 127 118 it('should throw error for unsupported DID method', async () => { 128 119 const notary = new ATProtocolNotary({ 129 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 130 120 schemaUID: '0xschemauid', 131 121 }); 132 122 ··· 142 132 }); 143 133 144 134 const notary = new ATProtocolNotary({ 145 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 146 135 schemaUID: '0xschemauid', 147 136 }); 148 137 ··· 155 144 describe('fetchRecord', () => { 156 145 it('should fetch record from PDS', async () => { 157 146 const notary = new ATProtocolNotary({ 158 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 159 147 schemaUID: '0xschemauid', 160 148 }); 161 149 ··· 182 170 expect(result.pds).toBe('https://pds.example.com'); 183 171 expect(result.transactionHash).toBeTruthy(); 184 172 }); 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 173 }); 196 174 197 175 describe('verifyAttestation', () => { 198 176 it('should verify attestation successfully', async () => { 199 - const notary = new ATProtocolNotary({ 200 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 177 + const notary = new ATProtocolNotary({ 201 178 schemaUID: '0xschemauid', 202 179 }); 203 180 ··· 214 191 215 192 describe('compareWithCurrent', () => { 216 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 + 217 227 const notary = new ATProtocolNotary({ 218 - privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 219 228 schemaUID: '0xschemauid', 220 229 }); 221 230 ··· 223 232 const comparison = await notary.compareWithCurrent(attestation); 224 233 225 234 expect(comparison.exists).toBe(true); 226 - expect(comparison.currentHash).toBeTruthy(); 235 + expect(comparison.cidMatches).toBe(false); 236 + expect(comparison.hashMatches).toBe(false); 227 237 }); 228 238 }); 239 + 229 240 });
+31
tests/unit/utils.test.ts
··· 79 79 expect(url).toBe('https://sepolia.easscan.org/attestation/view/0xabc123'); 80 80 }); 81 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 + });