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