Notarize AT Protocol records on Ethereum using EAS (experiment)

Revert "use cbor content hashing"

This reverts commit dff675d2e053a7e5ce41f6fd0a080fdaa4bc96dd.

Changed files
+51 -125
src
tests
integration
unit
+2 -3
package.json
··· 29 29 "dependencies": { 30 30 "@atproto/api": "^0.12.29", 31 31 "@ethereum-attestation-service/eas-sdk": "^2.9.0", 32 - "@ipld/dag-cbor": "^9.2.5", 33 - "ethers": "^6.15.0", 34 - "js-yaml": "^4.1.0" 32 + "js-yaml": "^4.1.0", 33 + "ethers": "^6.15.0" 35 34 }, 36 35 "devDependencies": { 37 36 "@types/js-yaml": "^4.0.9",
-23
pnpm-lock.yaml
··· 14 14 '@ethereum-attestation-service/eas-sdk': 15 15 specifier: ^2.9.0 16 16 version: 2.9.0(typescript@5.9.3)(zod@3.25.76) 17 - '@ipld/dag-cbor': 18 - specifier: ^9.2.5 19 - version: 9.2.5 20 17 ethers: 21 18 specifier: ^6.15.0 22 19 version: 6.15.0 ··· 342 339 resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} 343 340 engines: {node: '>=14'} 344 341 345 - '@ipld/dag-cbor@9.2.5': 346 - resolution: {integrity: sha512-84wSr4jv30biui7endhobYhXBQzQE4c/wdoWlFrKcfiwH+ofaPg8fwsM8okX9cOzkkrsAsNdDyH3ou+kiLquwQ==} 347 - engines: {node: '>=16.0.0', npm: '>=7.0.0'} 348 - 349 342 '@isaacs/cliui@8.0.2': 350 343 resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 351 344 engines: {node: '>=12'} ··· 910 903 camelcase@6.3.0: 911 904 resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} 912 905 engines: {node: '>=10'} 913 - 914 - cborg@4.2.15: 915 - resolution: {integrity: sha512-T+YVPemWyXcBVQdp0k61lQp2hJniRNmul0lAwTj2DTS/6dI4eCq/MRMucGqqvFqMBfmnD8tJ9aFtPu5dEGAbgw==} 916 - hasBin: true 917 906 918 907 chai@5.3.3: 919 908 resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} ··· 1534 1523 ms@2.1.3: 1535 1524 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1536 1525 1537 - multiformats@13.4.1: 1538 - resolution: {integrity: sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q==} 1539 - 1540 1526 multiformats@9.9.0: 1541 1527 resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1542 1528 ··· 2486 2472 2487 2473 '@fastify/busboy@2.1.1': {} 2488 2474 2489 - '@ipld/dag-cbor@9.2.5': 2490 - dependencies: 2491 - cborg: 4.2.15 2492 - multiformats: 13.4.1 2493 - 2494 2475 '@isaacs/cliui@8.0.2': 2495 2476 dependencies: 2496 2477 string-width: 5.1.2 ··· 3067 3048 get-intrinsic: 1.3.0 3068 3049 3069 3050 camelcase@6.3.0: {} 3070 - 3071 - cborg@4.2.15: {} 3072 3051 3073 3052 chai@5.3.3: 3074 3053 dependencies: ··· 3804 3783 yargs-unparser: 2.0.0 3805 3784 3806 3785 ms@2.1.3: {} 3807 - 3808 - multiformats@13.4.1: {} 3809 3786 3810 3787 multiformats@9.9.0: {} 3811 3788
+5 -10
src/lib/notary.ts
··· 4 4 const { EAS, SchemaEncoder, SchemaRegistry, NO_EXPIRATION } = EASPackage; 5 5 6 6 import type { NotaryConfig, NotarizationResult, AttestationData } from './types'; 7 - import { parseRecordURI, hashContent, getExplorerURL, extractHashFromCID } from './utils'; 7 + import { parseRecordURI, hashContent, getExplorerURL } from './utils'; 8 8 9 9 // Default schemas (deployed by atnotary maintainers) 10 10 const DEFAULT_SCHEMAS = { ··· 277 277 currentHash?: string; 278 278 }> { 279 279 try { 280 + // fetchRecord now returns { record, pds } 280 281 const { record } = await this.fetchRecord(attestationData.recordURI); 282 + console.log(record.value) 283 + const currentHash = hashContent(record.value); 281 284 const currentCid = record.cid; 282 285 283 - // Compute hash using DAG-CBOR (same as CID) 284 - const currentHash = hashContent(record.value); 285 - 286 - // Extract hash from CID for comparison 287 - const cidHash = extractHashFromCID(currentCid); 288 - 289 286 return { 290 287 exists: true, 291 288 cidMatches: currentCid === attestationData.cid, 292 - // Now contentHash should match the hash inside CID! 293 - hashMatches: currentHash === attestationData.contentHash && currentHash === cidHash, 289 + hashMatches: currentHash === attestationData.contentHash, 294 290 currentCid, 295 291 currentHash, 296 292 }; ··· 305 301 throw error; 306 302 } 307 303 } 308 - 309 304 310 305 /** 311 306 * Get signer address
+7 -27
src/lib/utils.ts
··· 1 1 import * as crypto from 'crypto'; 2 - import { encode } from '@ipld/dag-cbor'; 3 - import { CID } from 'multiformats/cid'; 4 - import * as sha256 from 'multiformats/hashes/sha256'; 5 2 6 3 export function parseRecordURI(uri: string): { did: string; collection: string; rkey: string } { 7 4 const match = uri.match(/^at:\/\/(did:[^\/]+)\/([^\/]+)\/(.+)$/); ··· 32 29 return sorted; 33 30 } 34 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 + 35 39 export function getExplorerURL(attestationUID: string, network: string = 'sepolia'): string { 36 40 const explorers: Record<string, string> = { 37 41 'sepolia': 'https://sepolia.easscan.org', ··· 44 48 const baseURL = explorers[network] || explorers['sepolia']; 45 49 return `${baseURL}/attestation/view/${attestationUID}`; 46 50 } 47 - 48 - // Extract hash bytes from CID 49 - export function extractHashFromCID(cidString: string): string { 50 - const cid = CID.parse(cidString); 51 - return '0x' + Buffer.from(cid.multihash.digest).toString('hex'); 52 - } 53 - 54 - // Hash content using DAG-CBOR (same as AT Protocol) 55 - export function hashContent(content: any): string { 56 - // Encode as DAG-CBOR (same as AT Protocol) 57 - const cborBytes = encode(content); 58 - 59 - // Hash with SHA-256 60 - const hash = crypto.createHash('sha256').update(cborBytes).digest('hex'); 61 - 62 - return '0x' + hash; 63 - } 64 - 65 - // Verify content matches CID 66 - export function verifyCID(content: any, cidString: string): boolean { 67 - const computedHash = hashContent(content); 68 - const cidHash = extractHashFromCID(cidString); 69 - return computedHash === cidHash; 70 - }
+17 -43
tests/integration/notary.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 2 import { ATProtocolNotary } from '../../src/lib/notary'; 3 - import { CID } from 'multiformats/cid'; 4 - import * as sha256 from 'multiformats/hashes/sha2'; 5 - import { encode } from '@ipld/dag-cbor'; 6 - 7 - // Generate real valid CIDs for test data 8 - const TEST_CONTENT_1 = { text: 'Test post', createdAt: '2024-01-01' }; 9 - const TEST_CONTENT_2 = { text: 'Modified post', createdAt: '2024-01-02' }; 10 - 11 - // Create real CIDs 12 - async function createCID(content: any): Promise<string> { 13 - const bytes = encode(content); 14 - const hash = await sha256.sha256.digest(bytes); 15 - const cid = CID.create(1, 0x71, hash); // CIDv1, dag-cbor 16 - return cid.toString(); 17 - } 18 - 19 - // Generate CIDs at module level (needs to be async) 20 - let TEST_CID_1: string; 21 - let TEST_CID_2: string; 22 - let TEST_HASH_1: string; 23 3 24 4 // Mock external dependencies 25 5 vi.mock('@atproto/api', () => ({ ··· 29 9 repo: { 30 10 getRecord: vi.fn().mockResolvedValue({ 31 11 data: { 32 - value: TEST_CONTENT_1, 33 - cid: TEST_CID_1, 12 + value: { text: 'Test post', createdAt: '2024-01-01' }, 13 + cid: 'bafyreiabc123', 34 14 }, 35 15 }), 36 16 }, ··· 79 59 encodeData: vi.fn().mockReturnValue('0xencodeddata'), 80 60 decodeData: vi.fn().mockReturnValue([ 81 61 { name: 'recordURI', value: { value: 'at://did:plc:test/app.bsky.feed.post/123' } }, 82 - { name: 'cid', value: { value: TEST_CID_1 } }, 83 - { name: 'contentHash', value: { value: TEST_HASH_1 } }, 62 + { name: 'cid', value: { value: 'bafyreiabc123' } }, 63 + { name: 'contentHash', value: { value: '0xa64b286ffd5cc55c57f4e9c74d1122aa081dc5f662648cb5cc5ced74e0e12cd5' } }, 84 64 { name: 'pds', value: { value: 'https://pds.example.com' } }, 85 65 { name: 'timestamp', value: { value: 1234567890 } }, 86 66 ]), ··· 93 73 global.fetch = vi.fn(); 94 74 95 75 describe('ATProtocolNotary', () => { 96 - beforeEach(async () => { 76 + beforeEach(() => { 97 77 vi.clearAllMocks(); 98 78 99 - // Generate real CIDs 100 - TEST_CID_1 = await createCID(TEST_CONTENT_1); 101 - TEST_CID_2 = await createCID(TEST_CONTENT_2); 102 - 103 - // Generate real hash 104 - const { hashContent } = await import('../../src/lib/utils'); 105 - TEST_HASH_1 = hashContent(TEST_CONTENT_1); 106 - 107 79 // Mock DID resolution 108 80 (global.fetch as any).mockResolvedValue({ 109 81 ok: true, ··· 178 150 const result = await notary.fetchRecord('at://did:plc:test/app.bsky.feed.post/123'); 179 151 180 152 expect(result.record.value.text).toBe('Test post'); 181 - expect(result.record.cid).toBe(TEST_CID_1); 153 + expect(result.record.cid).toBe('bafyreiabc123'); 182 154 expect(result.pds).toBe('https://pds.example.com'); 183 155 }); 184 156 }); ··· 194 166 195 167 expect(result.attestationUID).toBe('0xattestationuid123'); 196 168 expect(result.recordURI).toBe('at://did:plc:test/app.bsky.feed.post/123'); 197 - expect(result.cid).toBe(TEST_CID_1); 169 + expect(result.cid).toBe('bafyreiabc123'); 198 170 expect(result.pds).toBe('https://pds.example.com'); 199 171 expect(result.transactionHash).toBeTruthy(); 200 172 }); ··· 210 182 211 183 expect(result.uid).toBe('0xattestationuid123'); 212 184 expect(result.recordURI).toBe('at://did:plc:test/app.bsky.feed.post/123'); 213 - expect(result.cid).toBe(TEST_CID_1); 185 + expect(result.cid).toBe('bafyreiabc123'); 214 186 expect(result.pds).toBe('https://pds.example.com'); 215 187 expect(result.attester).toBe('0xattester'); 216 188 expect(result.revoked).toBe(false); ··· 225 197 226 198 const attestation = await notary.verifyAttestation('0xattestationuid123'); 227 199 const comparison = await notary.compareWithCurrent(attestation); 200 + console.log({ attestation, comparison }) 228 201 229 202 expect(comparison.exists).toBe(true); 230 203 expect(comparison.cidMatches).toBe(true); 231 204 expect(comparison.hashMatches).toBe(true); 232 - expect(comparison.currentCid).toBe(TEST_CID_1); 233 - expect(comparison.currentHash).toBe(TEST_HASH_1); 205 + expect(comparison.currentCid).toBeTruthy(); 206 + expect(comparison.currentHash).toBeTruthy(); 234 207 }); 235 208 236 209 it('should detect changed content', async () => { 237 - // Mock different content 210 + // Mock different CID and hash 238 211 const { AtpAgent } = await import('@atproto/api'); 239 - (AtpAgent as any).mockImplementationOnce(() => ({ 212 + (AtpAgent as any).mockImplementation(() => ({ 240 213 com: { 241 214 atproto: { 242 215 repo: { 243 216 getRecord: vi.fn().mockResolvedValue({ 244 217 data: { 245 - value: TEST_CONTENT_2, 246 - cid: TEST_CID_2, 218 + value: { text: 'Modified post', createdAt: '2024-01-02' }, 219 + cid: 'bafyreidifferent123', 247 220 }, 248 221 }), 249 222 }, ··· 260 233 261 234 expect(comparison.exists).toBe(true); 262 235 expect(comparison.cidMatches).toBe(false); 263 - expect(comparison.currentCid).toBe(TEST_CID_2); 236 + expect(comparison.hashMatches).toBe(false); 264 237 }); 265 238 }); 239 + 266 240 });
+20 -19
tests/unit/utils.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { parseRecordURI, hashContent, getExplorerURL, extractHashFromCID, verifyCID } from '../../src/lib/utils'; 2 + import { parseRecordURI, hashContent, getExplorerURL } from '../../src/lib/utils'; 3 3 4 4 describe('parseRecordURI', () => { 5 5 it('should parse valid AT Protocol URI', () => { ··· 27 27 }); 28 28 }); 29 29 30 - describe('hashContent with DAG-CBOR', () => { 31 - it('should produce hash that matches CID', () => { 30 + describe('hashContent', () => { 31 + it('should generate consistent SHA-256 hash', () => { 32 32 const content = { text: 'Hello World', createdAt: '2024-01-01' }; 33 - const contentHash = hashContent(content); 33 + const hash1 = hashContent(content); 34 + const hash2 = hashContent(content); 34 35 35 - // This hash should match the hash inside a CID of the same content 36 - expect(contentHash).toMatch(/^0x[a-f0-9]{64}$/); 36 + expect(hash1).toBe(hash2); 37 + expect(hash1).toMatch(/^0x[a-f0-9]{64}$/); 37 38 }); 38 - }); 39 39 40 - describe('extractHashFromCID', () => { 41 - it('should extract hash from valid CID', () => { 42 - const cid = 'bafyreig3iwk4yuvewp54jkzplqw4vxae5o3smtcfn2jxk7x4ewhicbuw4m'; // Real CID 43 - const hash = extractHashFromCID(cid); 40 + it('should generate different hashes for different content', () => { 41 + const content1 = { text: 'Hello' }; 42 + const content2 = { text: 'World' }; 44 43 45 - expect(hash).toMatch(/^0x[a-f0-9]{64}$/); 44 + expect(hashContent(content1)).not.toBe(hashContent(content2)); 46 45 }); 47 - }); 48 46 49 - describe('verifyCID', () => { 50 - it('should verify content matches CID', () => { 51 - const content = { text: 'Test' }; 52 - const contentHash = hashContent(content); 47 + it('should be order-sensitive', () => { 48 + const content1 = { a: 1, b: 2 }; 49 + const content2 = { b: 2, a: 1 }; 50 + 51 + // JSON.stringify is order-sensitive 52 + const hash1 = hashContent(content1); 53 + const hash2 = hashContent(content2); 53 54 54 - // In real usage, CID and content should match 55 - // This would need a real CID from AT Protocol 55 + expect(hash1).toBeTruthy(); 56 + expect(hash2).toBeTruthy(); 56 57 }); 57 58 }); 58 59