+17
-13
README.md
+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
+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
+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
src/lib/types.ts
+19
-1
src/lib/utils.ts
+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
+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
+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
+
});