Notarize AT Protocol records on Ethereum using EAS (experiment)

more updates

+2 -1
.gitignore
··· 3 3 node_modules 4 4 bun.lock 5 5 dist 6 - coverage 6 + coverage 7 + .atnotary*
+14 -21
README.md
··· 10 10 - **Infrastructure** - explorers, indexers, and tooling already exist 11 11 - **Multi-chain** - works on Ethereum, Base, Optimism, Arbitrum 12 12 13 - **Why AT Protocol:** 14 - Unlike Twitter, AT Protocol is federated and open. Users own their data, can switch servers, and the protocol is public. This makes it possible to build open infrastructure for accountability that isn't controlled by any company. 15 - 16 - **Principles:** 17 - - Transparency without judgment - just preserves facts, doesn't label "good" or "bad" 18 - - Decentralized - fetches directly from user's PDS, not centralized APIs 19 - - User choice - opt-in system, you choose what to notarize 20 - - Open infrastructure - anyone can verify, build on, or run their own instance 21 - 22 - Think of it as combining two open protocols: **AT Protocol** (decentralized social) + **EAS** (decentralized attestations) = transparent, accountable social web. 23 - 24 13 ## What Gets Attested 25 14 26 15 - `recordURI` - Full AT Protocol URI 27 16 - `cid` - AT Protocol's content identifier 28 - - `contentHash` - SHA-256 hash 17 + - `contentHash` - DAG-CBOR hash 29 18 - `pds` - Personal Data Server URL 30 19 - `timestamp` - When attested 31 20 ··· 37 26 38 27 ## Setup 39 28 40 - 1. **Create `.env` file:** 29 + 1. **Create config file:** 41 30 42 31 ```bash 43 - PRIVATE_KEY="0x..." 44 - # SCHEMA_UID is optional - default schemas are provided 32 + atnotary config 33 + ``` 34 + 35 + 2. **Edit `.atnotary.yaml`:** 36 + 37 + ```yaml 38 + privateKey: "0x..." # private key for writing 39 + network: base-sepolia # default network 45 40 ``` 46 41 47 - 2. **Get testnet ETH:** 42 + 3. **Get testnet ETH:** 48 43 - Sepolia: https://sepoliafaucet.com/ 49 44 - Base Sepolia: https://bridge.base.org/ 50 45 ··· 58 53 atnotary init --network sepolia 59 54 ``` 60 55 61 - Then add the `SCHEMA_UID` to your `.env` file. 56 + Then add the `schemaUID` to your `.atnotary.yaml` file. 62 57 63 58 ## Usage 64 59 ··· 79 74 import { ATProtocolNotary } from 'atnotary'; 80 75 81 76 const notary = new ATProtocolNotary({ 82 - privateKey: process.env.PRIVATE_KEY!, 83 - schemaUID: process.env.SCHEMA_UID!, 77 + privateKey: "0x...", // optional, just for writing 84 78 }, 'sepolia'); 85 79 86 80 const result = await notary.notarizeRecord('at://...'); ··· 104 98 105 99 ## License 106 100 107 - MIT 108 - 101 + MIT
+3 -2
package.json
··· 30 30 "@atproto/api": "^0.12.29", 31 31 "@ethereum-attestation-service/eas-sdk": "^2.9.0", 32 32 "@ipld/dag-cbor": "^9.2.5", 33 - "ethers": "^6.15.0" 33 + "ethers": "^6.15.0", 34 + "js-yaml": "^4.1.0" 34 35 }, 35 36 "devDependencies": { 37 + "@types/js-yaml": "^4.0.9", 36 38 "@types/node": "^20.19.21", 37 39 "@vitest/coverage-v8": "^3.2.4", 38 40 "chalk": "^5.6.2", 39 41 "commander": "^11.1.0", 40 - "dotenv": "^16.6.1", 41 42 "ora": "^8.2.0", 42 43 "tsx": "^4.20.6", 43 44 "typescript": "^5.9.3",
+11 -9
pnpm-lock.yaml
··· 20 20 ethers: 21 21 specifier: ^6.15.0 22 22 version: 6.15.0 23 + js-yaml: 24 + specifier: ^4.1.0 25 + version: 4.1.0 23 26 devDependencies: 27 + '@types/js-yaml': 28 + specifier: ^4.0.9 29 + version: 4.0.9 24 30 '@types/node': 25 31 specifier: ^20.19.21 26 32 version: 20.19.21 ··· 33 39 commander: 34 40 specifier: ^11.1.0 35 41 version: 11.1.0 36 - dotenv: 37 - specifier: ^16.6.1 38 - version: 16.6.1 39 42 ora: 40 43 specifier: ^8.2.0 41 44 version: 8.2.0 ··· 693 696 '@types/estree@1.0.8': 694 697 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 695 698 699 + '@types/js-yaml@4.0.9': 700 + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} 701 + 696 702 '@types/lru-cache@5.1.1': 697 703 resolution: {integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==} 698 704 ··· 1035 1041 diff@5.2.0: 1036 1042 resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} 1037 1043 engines: {node: '>=0.3.1'} 1038 - 1039 - dotenv@16.6.1: 1040 - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} 1041 - engines: {node: '>=12'} 1042 1044 1043 1045 dunder-proto@1.0.1: 1044 1046 resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} ··· 2830 2832 2831 2833 '@types/estree@1.0.8': {} 2832 2834 2835 + '@types/js-yaml@4.0.9': {} 2836 + 2833 2837 '@types/lru-cache@5.1.1': {} 2834 2838 2835 2839 '@types/ms@2.1.0': {} ··· 3193 3197 depd@2.0.0: {} 3194 3198 3195 3199 diff@5.2.0: {} 3196 - 3197 - dotenv@16.6.1: {} 3198 3200 3199 3201 dunder-proto@1.0.1: 3200 3202 dependencies:
+64 -33
src/cli.ts
··· 3 3 import { Command } from 'commander'; 4 4 import chalk from 'chalk'; 5 5 import ora from 'ora'; 6 - import * as dotenv from 'dotenv'; 6 + import * as path from 'path'; 7 + import * as os from 'os'; 8 + import * as fs from 'node:fs'; 7 9 import { ATProtocolNotary } from './lib/index.js'; 8 - 9 - dotenv.config(); 10 + import { loadConfig, getSchemaUID, createConfigTemplate } from './lib/config.js'; 10 11 11 12 const program = new Command(); 12 13 13 14 program 14 15 .name('atnotary') 15 16 .description('Notarize AT Protocol records on Ethereum using EAS') 16 - .version('0.1.0'); 17 + .version('0.1.0') 18 + .option('-c, --config <path>', 'Path to config file'); 19 + 20 + // Config command 21 + program 22 + .command('config') 23 + .description('Create config template') 24 + .option('-g, --global', 'Create in home directory') 25 + .option('-f, --force', 'Overwrite existing config file') 26 + .action((options) => { 27 + const configPath = options.global 28 + ? path.join(os.homedir(), '.atnotary.yaml') 29 + : path.join(process.cwd(), '.atnotary.yaml'); 30 + 31 + // Check if file exists 32 + if (fs.existsSync(configPath) && !options.force) { 33 + console.log(chalk.yellow(`\n⚠️ Config file already exists: ${configPath}`)); 34 + console.log(chalk.gray('Use --force to overwrite\n')); 35 + process.exit(1); 36 + } 37 + 38 + createConfigTemplate(configPath); 39 + console.log(chalk.green(`\n✅ Config template created: ${configPath}`)); 40 + console.log(chalk.yellow('\nEdit the file and add your private key and schema UIDs.\n')); 41 + }); 17 42 18 43 // Initialize command 19 44 program ··· 22 47 .option('-n, --network <network>', 'Network to use', 'sepolia') 23 48 .action(async (options) => { 24 49 try { 50 + const config = loadConfig(program.opts().config); 51 + 25 52 console.log(chalk.blue('🔧 Initializing EAS Schema')); 26 53 console.log(chalk.gray(`Network: ${options.network}\n`)); 27 54 28 55 const spinner = ora(`Connecting to ${options.network}...`).start(); 29 56 30 57 const notary = new ATProtocolNotary({ 31 - privateKey: process.env.PRIVATE_KEY!, 58 + privateKey: config.privateKey, 32 59 }, options.network); 33 60 34 61 const address = await notary.getAddress(); 35 62 spinner.succeed(`Connected to ${options.network}: ${address}`); 63 + console.log(chalk.blue('Account:'), chalk.cyan(address)); 36 64 37 65 spinner.start(`Creating schema on EAS (${options.network})...`); 38 66 const schemaUID = await notary.initializeSchema(); ··· 40 68 spinner.succeed('Schema created!'); 41 69 42 70 console.log(chalk.green('\n✅ Schema UID:'), chalk.cyan(schemaUID)); 43 - console.log(chalk.yellow('\n⚠️ Add this to your .env file:')); 44 - console.log(chalk.gray(`SCHEMA_UID="${schemaUID}"\n`)); 71 + console.log(chalk.yellow('\n⚠️ Add this to your .atnotary.json:')); 72 + console.log(chalk.gray(`\n"networks": {\n "${options.network}": {\n "schemaUID": "${schemaUID}"\n }\n}\n`)); 45 73 46 74 } catch (error: any) { 47 75 console.error(chalk.red('Error:'), error.message); ··· 53 81 program 54 82 .command('notarize <recordURI>') 55 83 .description('Notarize an AT Protocol record') 56 - .option('-n, --network <network>', 'Network to use', 'sepolia') 84 + .option('-n, --network <network>', 'Network to use') 57 85 .action(async (recordURI: string, options) => { 58 86 try { 87 + const config = loadConfig(program.opts().config); 88 + const network = options.network || config.network || 'sepolia'; 89 + 59 90 console.log(chalk.blue('🔐 AT Protocol Notary')); 60 - console.log(chalk.gray(`Network: ${options.network}\n`)); 91 + console.log(chalk.gray(`Network: ${network}\n`)); 61 92 62 93 const spinner = ora('Initializing...').start(); 63 94 64 95 const notary = new ATProtocolNotary({ 65 - privateKey: process.env.PRIVATE_KEY!, 66 - schemaUID: process.env.SCHEMA_UID, 67 - }, options.network); 96 + privateKey: config.privateKey, 97 + schemaUID: getSchemaUID(config, network), 98 + }, network); 68 99 69 - spinner.succeed('Initialized'); 100 + // Show account info 101 + const address = await notary.getAddress(); 102 + spinner.succeed(`Initialized with account: ${chalk.cyan(address)}`); 70 103 71 104 spinner.start('Resolving DID to PDS...'); 72 105 const { record, pds } = await notary.fetchRecord(recordURI); ··· 75 108 console.log(chalk.gray('\nRecord preview:')); 76 109 console.log(chalk.gray(JSON.stringify(record.value, null, 2).substring(0, 200) + '...\n')); 77 110 78 - spinner.start(`Creating attestation on ${options.network}...`); 111 + spinner.start(`Creating attestation on ${network}...`); 79 112 const result = await notary.notarizeRecord(recordURI); 80 113 spinner.succeed('Attestation created!'); 81 114 82 115 console.log(chalk.green('\n✅ Notarization Complete!\n')); 83 - console.log(chalk.blue('Network:'), chalk.gray(options.network)); 116 + console.log(chalk.blue('Network:'), chalk.gray(network)); 117 + console.log(chalk.blue('Attester:'), chalk.cyan(address)); 84 118 console.log(chalk.blue('Attestation UID:'), chalk.cyan(result.attestationUID)); 85 119 console.log(chalk.blue('Record URI:'), chalk.gray(result.recordURI)); 86 120 console.log(chalk.blue('CID:'), chalk.gray(result.cid)); ··· 92 126 console.log(chalk.cyan(result.explorerURL + '\n')); 93 127 94 128 console.log(chalk.yellow('🔍 Verify with:')); 95 - console.log(chalk.gray(`atnotary verify ${result.attestationUID} --network ${options.network}\n`)); 129 + console.log(chalk.gray(`atnotary verify ${result.attestationUID} --network ${network}\n`)); 96 130 97 131 } catch (error: any) { 98 132 console.error(chalk.red('Error:'), error.message); 99 133 process.exit(1); 100 134 } 101 135 }); 136 + 102 137 103 138 // Verify command 104 139 program 105 140 .command('verify <attestationUID>') 106 141 .description('Verify an attestation') 107 - .option('-n, --network <network>', 'Network to use', 'sepolia') 108 - .option('-c, --compare', 'Compare with current record state') 142 + .option('-n, --network <network>', 'Network to use') 143 + .option('--compare', 'Compare with current record state') 109 144 .action(async (attestationUID: string, options) => { 110 145 try { 146 + const config = loadConfig(program.opts().config); 147 + const network = options.network || config.network || 'sepolia'; 148 + 111 149 console.log(chalk.blue('🔍 Verifying Attestation')); 112 - console.log(chalk.gray(`Network: ${options.network}\n`)); 150 + console.log(chalk.gray(`Network: ${network}\n`)); 113 151 114 - const spinner = ora(`Fetching attestation from EAS (${options.network})...`).start(); 152 + const spinner = ora(`Fetching attestation from EAS (${network})...`).start(); 115 153 116 154 const notary = new ATProtocolNotary({ 117 - schemaUID: process.env.SCHEMA_UID, 118 - }, options.network); 155 + schemaUID: getSchemaUID(config, network), 156 + }, network); 119 157 120 158 const attestation = await notary.verifyAttestation(attestationUID); 121 159 spinner.succeed('Attestation found'); 122 160 123 161 console.log(chalk.green('\n✅ Attestation Valid\n')); 124 - console.log(chalk.blue('Network:'), chalk.gray(options.network)); 162 + console.log(chalk.blue('Network:'), chalk.gray(network)); 125 163 console.log(chalk.blue('UID:'), chalk.cyan(attestation.uid)); 126 164 console.log(chalk.blue('Record URI:'), chalk.gray(attestation.recordURI)); 127 165 console.log(chalk.blue('CID:'), chalk.gray(attestation.cid)); ··· 130 168 console.log(chalk.blue('Timestamp:'), chalk.gray(new Date(attestation.timestamp * 1000).toISOString())); 131 169 console.log(chalk.blue('Attester:'), chalk.gray(attestation.attester)); 132 170 133 - // Compare with current if requested 134 171 if (options.compare) { 135 172 console.log(chalk.yellow('\n🔄 Comparing with current record...')); 136 173 ··· 140 177 if (!comparison.exists) { 141 178 console.log(chalk.red('⚠ Record has been deleted')); 142 179 } else { 143 - // Check CID 144 180 if (comparison.cidMatches) { 145 - console.log(chalk.green('✓ CID matches (content identical via AT Protocol)')); 181 + console.log(chalk.green('✓ CID matches (content identical)')); 146 182 } else { 147 183 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}`)); 184 + console.log(chalk.gray(` Attested: ${attestation.cid}`)); 185 + console.log(chalk.gray(` Current: ${comparison.currentCid}`)); 150 186 } 151 187 152 - // Check content hash 153 188 if (comparison.hashMatches) { 154 189 console.log(chalk.green('✓ Content hash matches')); 155 190 } else { 156 191 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 192 } 160 193 161 - // Summary 162 194 if (comparison.cidMatches && comparison.hashMatches) { 163 195 console.log(chalk.green('\n✅ Record unchanged since attestation')); 164 196 } else { ··· 169 201 console.log(chalk.red(`⚠ Could not fetch current record: ${err.message}`)); 170 202 } 171 203 } 172 - 173 204 174 205 console.log(chalk.blue('\n📋 View on explorer:')); 175 206 console.log(chalk.cyan(attestation.explorerURL + '\n'));
+101
src/lib/config.ts
··· 1 + import * as fs from 'fs'; 2 + import * as path from 'path'; 3 + import * as os from 'os'; 4 + import yaml from 'js-yaml'; 5 + 6 + export interface Config { 7 + privateKey?: string; 8 + network?: string; 9 + networks?: { 10 + [network: string]: { 11 + schemaUID?: string; 12 + rpcUrl?: string; 13 + easContractAddress?: string; 14 + schemaRegistryAddress?: string; 15 + }; 16 + }; 17 + } 18 + 19 + /** 20 + * Load config from multiple sources (priority order): 21 + * 1. Custom config path (--config flag) 22 + * 2. ./.atnotary.yaml (current directory) 23 + * 3. ~/.atnotary.yaml (home directory) 24 + * 4. .env file (fallback) 25 + */ 26 + export function loadConfig(customPath?: string): Config { 27 + let config: Config = {}; 28 + 29 + // Try custom path first 30 + if (customPath) { 31 + if (fs.existsSync(customPath)) { 32 + config = loadYamlConfig(customPath); 33 + return config; 34 + } else { 35 + throw new Error(`Config file not found: ${customPath}`); 36 + } 37 + } 38 + 39 + // Try current directory 40 + const localConfig = path.join(process.cwd(), '.atnotary.yaml'); 41 + if (fs.existsSync(localConfig)) { 42 + config = loadYamlConfig(localConfig); 43 + return config; 44 + } 45 + 46 + // Try home directory 47 + const homeConfig = path.join(os.homedir(), '.atnotary.yaml'); 48 + if (fs.existsSync(homeConfig)) { 49 + config = loadYamlConfig(homeConfig); 50 + return config; 51 + } 52 + 53 + return config; 54 + } 55 + 56 + function loadYamlConfig(filePath: string): Config { 57 + try { 58 + const fileContents = fs.readFileSync(filePath, 'utf8'); 59 + const config = yaml.load(fileContents) as Config; 60 + return config; 61 + } catch (error: any) { 62 + throw new Error(`Failed to load config from ${filePath}: ${error.message}`); 63 + } 64 + } 65 + 66 + /** 67 + * Get schema UID for specific network 68 + */ 69 + export function getSchemaUID(config: Config, network: string): string | undefined { 70 + return config.networks?.[network]?.schemaUID; 71 + } 72 + 73 + /** 74 + * Create default config template 75 + */ 76 + export function createConfigTemplate(outputPath: string): void { 77 + const template = `# atnotary configuration 78 + # Private key for signing transactions (required for notarization) 79 + privateKey: "0x..." 80 + 81 + # Default network (sepolia, base-sepolia, base) 82 + network: sepolia 83 + 84 + # Network-specific configuration 85 + networks: 86 + sepolia: 87 + schemaUID: "0x..." 88 + # Optional: custom RPC URL 89 + # rpcUrl: "https://..." 90 + 91 + base-sepolia: 92 + schemaUID: "0x..." 93 + # rpcUrl: "https://sepolia.base.org" 94 + 95 + base: 96 + schemaUID: "0x..." 97 + # rpcUrl: "https://mainnet.base.org" 98 + `; 99 + 100 + fs.writeFileSync(outputPath, template, 'utf8'); 101 + }
+3 -5
src/lib/notary.ts
··· 9 9 // Default schemas (deployed by atnotary maintainers) 10 10 const DEFAULT_SCHEMAS = { 11 11 'sepolia': '0x2a39517604107c79acbb962fe809795a87b7e47b8682fd9fbd3f62694fcca47c', 12 - 'base-sepolia': '0x...', // TODO: Deploy and add schema UID 12 + 'base-sepolia': '0x2a39517604107c79acbb962fe809795a87b7e47b8682fd9fbd3f62694fcca47c', 13 13 'base': '0x...', // TODO: Deploy and add schema UID 14 14 }; 15 15 ··· 37 37 export class ATProtocolNotary { 38 38 private config: Required<NotaryConfig>; 39 39 private provider: ethers.JsonRpcProvider; 40 - private signer: ethers.Wallet; 40 + private signer?: ethers.Wallet; 41 41 private network: string; 42 42 private chainConfig: typeof CHAIN_CONFIG[keyof typeof CHAIN_CONFIG]; 43 43 private eas: any; 44 - private schemaRegistry: any; 44 + private schemaRegistry?: any; 45 45 46 46 constructor(config: NotaryConfig = {}, network: string = 'sepolia') { 47 47 this.network = network; ··· 247 247 const contentHash = decodedData.find(d => d.name === 'contentHash')?.value.value as string; 248 248 const pds = decodedData.find(d => d.name === 'pds')?.value.value as string; 249 249 const timestamp = Number(decodedData.find(d => d.name === 'timestamp')?.value.value); 250 - 251 - console.log({ extracted: extractHashFromCID(cid), real: contentHash }) 252 250 253 251 // Parse lexicon from recordURI (since it's not in schema) 254 252 const { collection: lexicon } = parseRecordURI(recordURI);