easctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/README.md +195 -0
  3. package/dist/index.js +31401 -0
  4. package/dist/index.js.map +1 -0
  5. package/manual-test/package-lock.json +4483 -0
  6. package/manual-test/package.json +15 -0
  7. package/package.json +40 -0
  8. package/src/__tests__/chains.test.ts +82 -0
  9. package/src/__tests__/clear-key.test.ts +40 -0
  10. package/src/__tests__/client.test.ts +168 -0
  11. package/src/__tests__/commands/attest.test.ts +203 -0
  12. package/src/__tests__/commands/get-attestation.test.ts +164 -0
  13. package/src/__tests__/commands/multi-attest.test.ts +166 -0
  14. package/src/__tests__/commands/multi-revoke.test.ts +114 -0
  15. package/src/__tests__/commands/multi-timestamp.test.ts +88 -0
  16. package/src/__tests__/commands/offchain-attest.test.ts +217 -0
  17. package/src/__tests__/commands/query-attestation.test.ts +84 -0
  18. package/src/__tests__/commands/query-attestations.test.ts +156 -0
  19. package/src/__tests__/commands/query-schema.test.ts +62 -0
  20. package/src/__tests__/commands/query-schemas.test.ts +110 -0
  21. package/src/__tests__/commands/revoke.test.ts +86 -0
  22. package/src/__tests__/commands/schema-get.test.ts +66 -0
  23. package/src/__tests__/commands/schema-register.test.ts +94 -0
  24. package/src/__tests__/commands/timestamp.test.ts +78 -0
  25. package/src/__tests__/config.test.ts +103 -0
  26. package/src/__tests__/graphql.test.ts +148 -0
  27. package/src/__tests__/integration/graphql-live.test.ts +103 -0
  28. package/src/__tests__/integration/offchain-signing.test.ts +252 -0
  29. package/src/__tests__/integration/schema-encoder.test.ts +131 -0
  30. package/src/__tests__/output.test.ts +138 -0
  31. package/src/__tests__/set-key.test.ts +58 -0
  32. package/src/__tests__/stdin.test.ts +15 -0
  33. package/src/chains.ts +99 -0
  34. package/src/client.ts +53 -0
  35. package/src/commands/attest.ts +73 -0
  36. package/src/commands/clear-key.ts +15 -0
  37. package/src/commands/get-attestation.ts +58 -0
  38. package/src/commands/multi-attest.ts +75 -0
  39. package/src/commands/multi-revoke.ts +60 -0
  40. package/src/commands/multi-timestamp.ts +43 -0
  41. package/src/commands/offchain-attest.ts +78 -0
  42. package/src/commands/query-attestation.ts +31 -0
  43. package/src/commands/query-attestations.ts +57 -0
  44. package/src/commands/query-schema.ts +24 -0
  45. package/src/commands/query-schemas.ts +35 -0
  46. package/src/commands/revoke.ts +48 -0
  47. package/src/commands/schema-get.ts +30 -0
  48. package/src/commands/schema-register.ts +49 -0
  49. package/src/commands/set-key.ts +19 -0
  50. package/src/commands/timestamp.ts +35 -0
  51. package/src/config.ts +41 -0
  52. package/src/graphql.ts +136 -0
  53. package/src/index.ts +74 -0
  54. package/src/output.ts +50 -0
  55. package/src/stdin.ts +15 -0
  56. package/src/validation.ts +15 -0
  57. package/tsconfig.json +16 -0
  58. package/tsup.config.ts +21 -0
  59. package/vitest.config.ts +7 -0
@@ -0,0 +1,30 @@
1
+ import { Command } from 'commander';
2
+ import { createReadOnlyEASClient } from '../client.js';
3
+ import { output, handleError } from '../output.js';
4
+ import { validateBytes32 } from '../validation.js';
5
+
6
+ export const schemaGetCommand = new Command('schema-get')
7
+ .description('Get a schema by UID')
8
+ .requiredOption('-u, --uid <uid>', 'Schema UID')
9
+ .option('-c, --chain <name>', 'Chain name', 'ethereum')
10
+ .option('--rpc-url <url>', 'Custom RPC URL')
11
+ .action(async (opts) => {
12
+ try {
13
+ validateBytes32(opts.uid, 'schema UID');
14
+
15
+ const client = createReadOnlyEASClient(opts.chain, opts.rpcUrl);
16
+ const schema = await client.schemaRegistry.getSchema({ uid: opts.uid });
17
+
18
+ output({
19
+ success: true,
20
+ data: {
21
+ uid: schema.uid,
22
+ schema: schema.schema,
23
+ resolver: schema.resolver,
24
+ revocable: schema.revocable,
25
+ },
26
+ });
27
+ } catch (err) {
28
+ handleError(err);
29
+ }
30
+ });
@@ -0,0 +1,49 @@
1
+ import { Command } from 'commander';
2
+ import { createEASClient } from '../client.js';
3
+ import { output, handleError } from '../output.js';
4
+ import { validateAddress } from '../validation.js';
5
+
6
+ export const schemaRegisterCommand = new Command('schema-register')
7
+ .description('Register a new schema')
8
+ .requiredOption('-s, --schema <definition>', 'Schema definition (e.g. "uint256 score, string name")')
9
+ .option('--resolver <address>', 'Resolver contract address', '0x0000000000000000000000000000000000000000')
10
+ .option('--revocable', 'Whether attestations using this schema can be revoked', true)
11
+ .option('--no-revocable', 'Make attestations non-revocable')
12
+ .option('-c, --chain <name>', 'Chain name', 'ethereum')
13
+ .option('--rpc-url <url>', 'Custom RPC URL')
14
+ .option('--dry-run', 'Estimate gas without sending the transaction')
15
+ .action(async (opts) => {
16
+ try {
17
+ if (opts.resolver !== '0x0000000000000000000000000000000000000000') {
18
+ validateAddress(opts.resolver, 'resolver');
19
+ }
20
+
21
+ const client = createEASClient(opts.chain, opts.rpcUrl);
22
+
23
+ const tx = await client.schemaRegistry.register({
24
+ schema: opts.schema,
25
+ resolverAddress: opts.resolver,
26
+ revocable: opts.revocable,
27
+ });
28
+
29
+ if (opts.dryRun) {
30
+ const gasEstimate = await tx.estimateGas();
31
+ output({ success: true, data: { dryRun: true, estimatedGas: gasEstimate.toString(), chain: opts.chain } });
32
+ } else {
33
+ const uid = await tx.wait();
34
+ output({
35
+ success: true,
36
+ data: {
37
+ uid,
38
+ txHash: tx.receipt!.hash,
39
+ schema: opts.schema,
40
+ resolver: opts.resolver,
41
+ revocable: opts.revocable,
42
+ chain: opts.chain,
43
+ },
44
+ });
45
+ }
46
+ } catch (err) {
47
+ handleError(err);
48
+ }
49
+ });
@@ -0,0 +1,19 @@
1
+ import { Command } from 'commander';
2
+ import { ethers } from 'ethers';
3
+ import { setStoredPrivateKey } from '../config.js';
4
+ import { output, handleError } from '../output.js';
5
+
6
+ export const setKeyCommand = new Command('set-key')
7
+ .description('Store your private key in ~/.eas-cli for future use')
8
+ .argument('<key>', 'Wallet private key (hex string, with or without 0x prefix)')
9
+ .action((key: string) => {
10
+ const normalized = key.startsWith('0x') ? key : `0x${key}`;
11
+
12
+ try {
13
+ const wallet = new ethers.Wallet(normalized);
14
+ setStoredPrivateKey(key);
15
+ output({ success: true, data: { address: wallet.address } });
16
+ } catch {
17
+ handleError(new Error('Invalid private key format'));
18
+ }
19
+ });
@@ -0,0 +1,35 @@
1
+ import { Command } from 'commander';
2
+ import { createEASClient } from '../client.js';
3
+ import { output, handleError } from '../output.js';
4
+
5
+ export const timestampCommand = new Command('timestamp')
6
+ .description('Timestamp data on-chain')
7
+ .requiredOption('-d, --data <bytes32>', 'Data to timestamp (bytes32 hex string)')
8
+ .option('-c, --chain <name>', 'Chain name', 'ethereum')
9
+ .option('--rpc-url <url>', 'Custom RPC URL')
10
+ .option('--dry-run', 'Estimate gas without sending the transaction')
11
+ .action(async (opts) => {
12
+ try {
13
+ const client = createEASClient(opts.chain, opts.rpcUrl);
14
+
15
+ const tx = await client.eas.timestamp(opts.data);
16
+
17
+ if (opts.dryRun) {
18
+ const gasEstimate = await tx.estimateGas();
19
+ output({ success: true, data: { dryRun: true, estimatedGas: gasEstimate.toString(), chain: opts.chain } });
20
+ } else {
21
+ const timestamp = await tx.wait();
22
+ output({
23
+ success: true,
24
+ data: {
25
+ timestamp: timestamp.toString(),
26
+ txHash: tx.receipt!.hash,
27
+ data: opts.data,
28
+ chain: opts.chain,
29
+ },
30
+ });
31
+ }
32
+ } catch (err) {
33
+ handleError(err);
34
+ }
35
+ });
package/src/config.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ interface EASConfig {
6
+ privateKey?: string;
7
+ }
8
+
9
+ export function getConfigPath(): string {
10
+ return join(homedir(), '.eas-cli');
11
+ }
12
+
13
+ export function readConfig(): EASConfig {
14
+ try {
15
+ const data = readFileSync(getConfigPath(), 'utf-8');
16
+ return JSON.parse(data);
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function writeConfig(config: EASConfig): void {
23
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
24
+ }
25
+
26
+ export function getStoredPrivateKey(): string | undefined {
27
+ return readConfig().privateKey;
28
+ }
29
+
30
+ export function setStoredPrivateKey(key: string): void {
31
+ const normalized = key.startsWith('0x') ? key : `0x${key}`;
32
+ const config = readConfig();
33
+ config.privateKey = normalized;
34
+ writeConfig(config);
35
+ }
36
+
37
+ export function clearStoredPrivateKey(): void {
38
+ const config = readConfig();
39
+ delete config.privateKey;
40
+ writeConfig(config);
41
+ }
package/src/graphql.ts ADDED
@@ -0,0 +1,136 @@
1
+ export const EASSCAN_URLS: Record<string, string> = {
2
+ ethereum: 'https://easscan.org',
3
+ sepolia: 'https://sepolia.easscan.org',
4
+ base: 'https://base.easscan.org',
5
+ 'base-sepolia': 'https://base-sepolia.easscan.org',
6
+ optimism: 'https://optimism.easscan.org',
7
+ 'optimism-sepolia': 'https://optimism-sepolia.easscan.org',
8
+ arbitrum: 'https://arbitrum.easscan.org',
9
+ 'arbitrum-sepolia': 'https://arbitrum-sepolia.easscan.org',
10
+ polygon: 'https://polygon.easscan.org',
11
+ scroll: 'https://scroll.easscan.org',
12
+ linea: 'https://linea.easscan.org',
13
+ celo: 'https://celo.easscan.org',
14
+ };
15
+
16
+ export function getEASScanUrl(chainName: string): string {
17
+ const url = EASSCAN_URLS[chainName];
18
+ if (!url) {
19
+ throw new Error(`No EASScan URL for chain "${chainName}"`);
20
+ }
21
+ return url;
22
+ }
23
+
24
+ export function getGraphQLEndpoint(chainName: string): string {
25
+ return `${getEASScanUrl(chainName)}/graphql`;
26
+ }
27
+
28
+ export async function graphqlQuery(
29
+ chainName: string,
30
+ query: string,
31
+ variables: Record<string, unknown> = {}
32
+ ): Promise<any> {
33
+ const endpoint = getGraphQLEndpoint(chainName);
34
+ const res = await fetch(endpoint, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ query, variables }),
38
+ });
39
+
40
+ if (!res.ok) {
41
+ throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}`);
42
+ }
43
+
44
+ const json = await res.json();
45
+ if (json.errors?.length) {
46
+ throw new Error(`GraphQL error: ${json.errors[0].message}`);
47
+ }
48
+ return json.data;
49
+ }
50
+
51
+ export const QUERIES = {
52
+ getSchema: `
53
+ query GetSchema($id: String!) {
54
+ schema(where: { id: $id }) {
55
+ id
56
+ schema
57
+ creator
58
+ resolver
59
+ revocable
60
+ txid
61
+ time
62
+ }
63
+ }
64
+ `,
65
+ getAttestation: `
66
+ query GetAttestation($id: String!) {
67
+ attestation(where: { id: $id }) {
68
+ id
69
+ attester
70
+ recipient
71
+ time
72
+ expirationTime
73
+ revocationTime
74
+ revoked
75
+ revocable
76
+ schemaId
77
+ data
78
+ decodedDataJson
79
+ isOffchain
80
+ txid
81
+ }
82
+ }
83
+ `,
84
+ getAttestationsBySchema: `
85
+ query GetAttestationsBySchema($schemaId: String!, $take: Int, $skip: Int) {
86
+ attestations(
87
+ where: { schemaId: { equals: $schemaId } }
88
+ take: $take
89
+ skip: $skip
90
+ orderBy: [{ time: desc }]
91
+ ) {
92
+ id
93
+ attester
94
+ recipient
95
+ time
96
+ revoked
97
+ decodedDataJson
98
+ isOffchain
99
+ }
100
+ }
101
+ `,
102
+ getAttestationsByAttester: `
103
+ query GetAttestationsByAttester($attester: String!, $take: Int, $skip: Int) {
104
+ attestations(
105
+ where: { attester: { equals: $attester } }
106
+ take: $take
107
+ skip: $skip
108
+ orderBy: [{ time: desc }]
109
+ ) {
110
+ id
111
+ recipient
112
+ schemaId
113
+ time
114
+ revoked
115
+ decodedDataJson
116
+ isOffchain
117
+ }
118
+ }
119
+ `,
120
+ getSchemata: `
121
+ query GetSchemata($creator: String, $take: Int, $skip: Int) {
122
+ schemata(
123
+ where: { creator: { equals: $creator } }
124
+ take: $take
125
+ skip: $skip
126
+ orderBy: [{ time: desc }]
127
+ ) {
128
+ id
129
+ schema
130
+ creator
131
+ revocable
132
+ time
133
+ }
134
+ }
135
+ `,
136
+ };
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { Command } from 'commander';
2
+ import { setJsonMode, output } from './output.js';
3
+ import { listChains } from './chains.js';
4
+ import { attestCommand } from './commands/attest.js';
5
+ import { revokeCommand } from './commands/revoke.js';
6
+ import { getAttestationCommand } from './commands/get-attestation.js';
7
+ import { schemaRegisterCommand } from './commands/schema-register.js';
8
+ import { schemaGetCommand } from './commands/schema-get.js';
9
+ import { multiAttestCommand } from './commands/multi-attest.js';
10
+ import { offchainAttestCommand } from './commands/offchain-attest.js';
11
+ import { timestampCommand } from './commands/timestamp.js';
12
+ import { querySchemaCommand } from './commands/query-schema.js';
13
+ import { queryAttestationCommand } from './commands/query-attestation.js';
14
+ import { queryAttestationsCommand } from './commands/query-attestations.js';
15
+ import { querySchemasCommand } from './commands/query-schemas.js';
16
+ import { multiRevokeCommand } from './commands/multi-revoke.js';
17
+ import { multiTimestampCommand } from './commands/multi-timestamp.js';
18
+ import { setKeyCommand } from './commands/set-key.js';
19
+ import { clearKeyCommand } from './commands/clear-key.js';
20
+
21
+ const program = new Command();
22
+
23
+ program
24
+ .name('easctl')
25
+ .description('Ethereum Attestation Service CLI — create, revoke, and query attestations')
26
+ .version(process.env.CLI_VERSION || '0.0.0-dev')
27
+ .option('--json', 'Output results as JSON (useful for agents and scripting)')
28
+ .hook('preAction', (thisCommand, actionCommand) => {
29
+ if (thisCommand.opts().json || actionCommand.opts().json) {
30
+ setJsonMode(true);
31
+ }
32
+ });
33
+
34
+ // Attestation commands
35
+ program.addCommand(attestCommand);
36
+ program.addCommand(multiAttestCommand);
37
+ program.addCommand(offchainAttestCommand);
38
+ program.addCommand(revokeCommand);
39
+ program.addCommand(multiRevokeCommand);
40
+ program.addCommand(getAttestationCommand);
41
+
42
+ // Schema commands
43
+ program.addCommand(schemaRegisterCommand);
44
+ program.addCommand(schemaGetCommand);
45
+
46
+ // Timestamp commands
47
+ program.addCommand(timestampCommand);
48
+ program.addCommand(multiTimestampCommand);
49
+
50
+ // GraphQL query commands
51
+ program.addCommand(querySchemaCommand);
52
+ program.addCommand(queryAttestationCommand);
53
+ program.addCommand(queryAttestationsCommand);
54
+ program.addCommand(querySchemasCommand);
55
+
56
+ // Key management commands
57
+ program.addCommand(setKeyCommand);
58
+ program.addCommand(clearKeyCommand);
59
+
60
+ // Chains command
61
+ program
62
+ .command('chains')
63
+ .description('List supported chains and their EAS contract addresses')
64
+ .action(() => {
65
+ const chains = listChains();
66
+ output({ success: true, data: { chains } });
67
+ });
68
+
69
+ // Add --json to every subcommand so it works regardless of position
70
+ for (const cmd of program.commands) {
71
+ cmd.option('--json', 'Output results as JSON');
72
+ }
73
+
74
+ program.parse();
package/src/output.ts ADDED
@@ -0,0 +1,50 @@
1
+ export interface CLIOutput {
2
+ success: boolean;
3
+ data?: Record<string, unknown>;
4
+ error?: string;
5
+ }
6
+
7
+ let jsonMode = false;
8
+
9
+ export function setJsonMode(enabled: boolean) {
10
+ jsonMode = enabled;
11
+ }
12
+
13
+ export function isJsonMode(): boolean {
14
+ return jsonMode;
15
+ }
16
+
17
+ export function output(result: CLIOutput) {
18
+ if (jsonMode) {
19
+ console.log(JSON.stringify(result, bigintReplacer, 2));
20
+ } else if (result.success && result.data) {
21
+ for (const [key, value] of Object.entries(result.data)) {
22
+ if (typeof value === 'object' && value !== null) {
23
+ console.log(`${key}:`);
24
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
25
+ console.log(` ${k}: ${formatValue(v)}`);
26
+ }
27
+ } else {
28
+ console.log(`${key}: ${formatValue(value)}`);
29
+ }
30
+ }
31
+ } else if (!result.success && result.error) {
32
+ console.error(`Error: ${result.error}`);
33
+ }
34
+ }
35
+
36
+ function formatValue(v: unknown): string {
37
+ if (typeof v === 'bigint') return v.toString();
38
+ if (typeof v === 'object' && v !== null) return JSON.stringify(v, bigintReplacer);
39
+ return String(v);
40
+ }
41
+
42
+ function bigintReplacer(_key: string, value: unknown): unknown {
43
+ return typeof value === 'bigint' ? value.toString() : value;
44
+ }
45
+
46
+ export function handleError(err: unknown): never {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ output({ success: false, error: message });
49
+ process.exit(1);
50
+ }
package/src/stdin.ts ADDED
@@ -0,0 +1,15 @@
1
+ export async function readStdin(): Promise<string> {
2
+ const chunks: Buffer[] = [];
3
+ for await (const chunk of process.stdin) {
4
+ chunks.push(chunk);
5
+ }
6
+ const text = Buffer.concat(chunks).toString('utf-8').trim();
7
+ if (!text) {
8
+ throw new Error('No data received from stdin');
9
+ }
10
+ return text;
11
+ }
12
+
13
+ export async function resolveInput(value: string): Promise<string> {
14
+ return value === '-' ? readStdin() : value;
15
+ }
@@ -0,0 +1,15 @@
1
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
2
+
3
+ export function validateAddress(value: string, label: string): void {
4
+ if (!ADDRESS_RE.test(value)) {
5
+ throw new Error(`Invalid ${label} address: expected 0x + 40 hex characters, got "${value}"`);
6
+ }
7
+ }
8
+
9
+ const BYTES32_RE = /^0x[0-9a-fA-F]{64}$/;
10
+
11
+ export function validateBytes32(value: string, label: string): void {
12
+ if (!BYTES32_RE.test(value)) {
13
+ throw new Error(`Invalid ${label}: expected 0x + 64 hex characters, got "${value}"`);
14
+ }
15
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "resolveJsonModule": true,
9
+ "skipLibCheck": true,
10
+ "outDir": "dist",
11
+ "rootDir": "src",
12
+ "declaration": true,
13
+ "sourceMap": true
14
+ },
15
+ "include": ["src"]
16
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from 'tsup';
2
+ import pkg from './package.json';
3
+
4
+ export default defineConfig({
5
+ entry: ['src/index.ts'],
6
+ format: ['cjs'],
7
+ target: 'node18',
8
+ clean: true,
9
+ sourcemap: true,
10
+ noExternal: [
11
+ '@ethereum-attestation-service/eas-sdk',
12
+ '@ethereum-attestation-service/eas-contracts',
13
+ '@ethereum-attestation-service/eas-contracts-legacy',
14
+ ],
15
+ define: {
16
+ 'process.env.CLI_VERSION': JSON.stringify(pkg.version),
17
+ },
18
+ banner: {
19
+ js: '#!/usr/bin/env node',
20
+ },
21
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/__tests__/**/*.test.ts'],
6
+ },
7
+ });