+2
-1
.gitignore
+2
-1
.gitignore
+14
-21
README.md
+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
+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
+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
+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
+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
+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);