@provex/eas 1.11.0 → 2.0.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
4
+ import { adaptViemAccountToEasSigner, buildDelegatedEasAttestationRequest, buildDelegatedEasAttestationRequestForChain, buildOffchainEasAttestationForChain, resolveEasSdkExports, } from './eas-builder.js';
5
+ import { getEasAddress, getSchemaUid } from './eas.js';
6
+ const attester = privateKeyToAccount(generatePrivateKey());
7
+ const CLAIM = {
8
+ platformHash: `0x${'11'.repeat(32)}`,
9
+ handle: 'alice',
10
+ user: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
11
+ expiresAt: 1800000000n,
12
+ };
13
+ describe('buildOffchainEasAttestationForChain', () => {
14
+ it('binds the pinned EAS address and schema unique identifier of the chain', async () => {
15
+ // WHY: pairing the wrong contract with the wrong schema would produce an
16
+ // attestation that no verifier on that chain accepts.
17
+ const result = await buildOffchainEasAttestationForChain({ claim: CLAIM, attester, chainId: 369 });
18
+ assert.equal(result.domain.chainId, 369);
19
+ assert.equal(result.domain.verifyingContract.toLowerCase(), getEasAddress(369).toLowerCase());
20
+ assert.equal(result.message.schema, getSchemaUid(369));
21
+ });
22
+ });
23
+ describe('buildDelegatedEasAttestationRequestForChain', () => {
24
+ const baseArgs = { claim: CLAIM, attester, nonce: 3n, deadline: 0n };
25
+ const signRaw = (chainId, contractVersion) => buildDelegatedEasAttestationRequest({
26
+ ...baseArgs,
27
+ chainId,
28
+ eas: getEasAddress(chainId),
29
+ schemaUid: getSchemaUid(chainId),
30
+ contractVersion,
31
+ });
32
+ it('signs PulseChain with the forked EAS contract version 1.4.0', async () => {
33
+ // WHY: the EIP-712 domain version must match the deployed contract or
34
+ // attestByDelegation reverts with InvalidSignature.
35
+ const viaChain = await buildDelegatedEasAttestationRequestForChain({ ...baseArgs, chainId: 369 });
36
+ assert.deepEqual(viaChain, await signRaw(369, '1.4.0'));
37
+ });
38
+ it('signs Ethereum mainnet with the official EAS contract version 0.26', async () => {
39
+ process.env.EAS_SCHEMA_UID_1_OVERRIDE = `0x${'99'.repeat(32)}`;
40
+ try {
41
+ const viaChain = await buildDelegatedEasAttestationRequestForChain({ ...baseArgs, chainId: 1 });
42
+ assert.deepEqual(viaChain, await signRaw(1, '0.26'));
43
+ assert.notDeepEqual(viaChain, await signRaw(1, '1.4.0'));
44
+ }
45
+ finally {
46
+ delete process.env.EAS_SCHEMA_UID_1_OVERRIDE;
47
+ }
48
+ });
49
+ it('signs Sepolia with version 0.26 as well', async () => {
50
+ const viaChain = await buildDelegatedEasAttestationRequestForChain({ ...baseArgs, chainId: 11155111 });
51
+ assert.deepEqual(viaChain, await signRaw(11155111, '0.26'));
52
+ });
53
+ it('honors an explicit contract version over the chain default', async () => {
54
+ // WHY: the publish path reads the live contract version() and must win.
55
+ const viaChain = await buildDelegatedEasAttestationRequestForChain({
56
+ ...baseArgs,
57
+ chainId: 369,
58
+ contractVersion: '2.0.0',
59
+ });
60
+ assert.deepEqual(viaChain, await signRaw(369, '2.0.0'));
61
+ });
62
+ });
63
+ describe('adaptViemAccountToEasSigner', () => {
64
+ it('refuses to sign when the SDK hands over no types', async () => {
65
+ // WHY: signing with no primary type would hash an empty struct; fail fast.
66
+ const signer = adaptViemAccountToEasSigner(attester);
67
+ await assert.rejects(() => signer.signTypedData({}, {}, {}), /empty types/);
68
+ });
69
+ it('exposes the account address to the SDK', async () => {
70
+ assert.equal(await adaptViemAccountToEasSigner(attester).getAddress(), attester.address);
71
+ });
72
+ });
73
+ describe('resolveEasSdkExports', () => {
74
+ const kit = {
75
+ Offchain: class {
76
+ },
77
+ OffchainAttestationVersion: {},
78
+ SchemaEncoder: class {
79
+ },
80
+ Delegated: class {
81
+ },
82
+ };
83
+ it('accepts a flat namespace', () => {
84
+ assert.equal(resolveEasSdkExports(kit), kit);
85
+ });
86
+ it('unwraps a namespace that nests the kit under default', () => {
87
+ assert.equal(resolveEasSdkExports({ default: kit }), kit);
88
+ });
89
+ it('throws when the kit is on neither the namespace nor its default', () => {
90
+ assert.throws(() => resolveEasSdkExports({ default: {} }), /cannot locate SchemaEncoder/);
91
+ assert.throws(() => resolveEasSdkExports({}), /cannot locate SchemaEncoder/);
92
+ });
93
+ });
@@ -1,6 +1,21 @@
1
1
  import type * as EasSdkModule from '@ethereum-attestation-service/eas-sdk';
2
2
  import type { Address, Hex, LocalAccount } from 'viem';
3
3
  declare const easSdkNamespace: typeof EasSdkModule;
4
+ export type EasSdkExports = {
5
+ Offchain: typeof easSdkNamespace.Offchain;
6
+ OffchainAttestationVersion: typeof easSdkNamespace.OffchainAttestationVersion;
7
+ SchemaEncoder: typeof easSdkNamespace.SchemaEncoder;
8
+ Delegated: typeof easSdkNamespace.Delegated;
9
+ };
10
+ /**
11
+ * Locate the SDK's named exports on whichever namespace shape the runtime
12
+ * resolver produced: the flat namespace (Node CommonJS, bundlers) or a
13
+ * namespace that nests them under `default`.
14
+ *
15
+ * @throws if neither shape carries `SchemaEncoder` — signing with a half-loaded
16
+ * kit would fail later with a far less useful error.
17
+ */
18
+ export declare const resolveEasSdkExports: (namespace: unknown) => EasSdkExports;
4
19
  type OffchainAttestationVersionEnum = (typeof easSdkNamespace)['OffchainAttestationVersion'][keyof (typeof easSdkNamespace)['OffchainAttestationVersion']];
5
20
  /**
6
21
  * @provex/eas — off-chain Ethereum Attestation Service attestation builder.
@@ -115,6 +130,25 @@ export declare const encodeIdentityClaim: (claim: IdentityClaim) => Hex;
115
130
  export declare const attestationExpirationTime: (options?: {
116
131
  nowSeconds?: number;
117
132
  }) => bigint;
133
+ /**
134
+ * Adapt a viem `LocalAccount` to the ethers-shaped `TypeDataSigner`
135
+ * the EAS SDK expects. The SDK only calls `signTypedData(domain, types,
136
+ * value)` and reads `getAddress()`; everything else on `Addressable` is
137
+ * unused.
138
+ *
139
+ * The `as never` casts are unavoidable — viem's typed-data types are
140
+ * deeply generic and ethers' `TypedDataField` shape doesn't unify with
141
+ * them. Both libraries produce the same EIP-712 bytes, so the cast is a
142
+ * type-erasure boundary, not a semantic one.
143
+ */
144
+ /** Adapt a viem account to the ethers-shaped signer the SDK expects. Exported for tests. */
145
+ export declare const adaptViemAccountToEasSigner: (account: LocalAccount) => {
146
+ getAddress: () => Promise<string>;
147
+ signTypedData: (domain: Record<string, unknown>, types: Record<string, Array<{
148
+ name: string;
149
+ type: string;
150
+ }>>, value: Record<string, unknown>) => Promise<string>;
151
+ };
118
152
  /**
119
153
  * Build a fully-signed off-chain EAS attestation for a Provex identity
120
154
  * claim. Pure function modulo the signature: no network I/O, no on-chain
@@ -322,6 +356,25 @@ export declare const buildDelegatedEasAttestationRequest: (args: BuildDelegatedE
322
356
  export declare const buildDelegatedEasAttestationRequestForChain: (args: Omit<BuildDelegatedEasAttestationRequestArgs, "eas" | "schemaUid" | "contractVersion"> & {
323
357
  contractVersion?: string;
324
358
  }) => Promise<DelegatedAttestationSignature>;
359
+ /**
360
+ * Canonical EAS contract version per chain. Pulse mainnet/testnet
361
+ * deploys forked the EAS contract reporting version v1.4.0; Ethereum
362
+ * mainnet/Sepolia are on the official v0.26 deploy. The EIP-712 domain
363
+ * MUST use this exact value or `attestByDelegation` rejects the
364
+ * signature with `InvalidSignature()`.
365
+ *
366
+ * This is only a FALLBACK default. The on-chain publish path
367
+ * (`packages/notary-gate/src/lib/chain-attest.ts`) reads the deployed
368
+ * contract's `version()` live and passes it as `contractVersion`, so it
369
+ * stays correct even if the PulseChain EAS fork bumps its version again.
370
+ * Keep this constant aligned with the live deploys regardless.
371
+ *
372
+ * Mirrors `easVersionForChain` in
373
+ * `packages/notary-gate/src/lib/offchain-easscan.ts:43` — kept in sync
374
+ * by hand because pulling the gate file into `@provex/eas` would
375
+ * create a dependency loop.
376
+ */
377
+ export declare const easContractVersionForChain: (chainId: number) => string;
325
378
  export type BuildDelegatedReleaseAttestationRequestArgs = {
326
379
  attester: LocalAccount;
327
380
  claim: ReleaseClaim;
@@ -4,15 +4,24 @@ import { getAgeSchemaUid, getEasAddress, getReleaseSchemaUid, getSchemaUid, PROV
4
4
  // Runtime namespace, typed against the erased `import type` above so the shims
5
5
  // below (`typeof easSdkNamespace`) keep their exact SDK types.
6
6
  const easSdkNamespace = easSdkNamespaceRaw;
7
- const easSdkExports = (() => {
8
- const flat = easSdkNamespace;
7
+ /**
8
+ * Locate the SDK's named exports on whichever namespace shape the runtime
9
+ * resolver produced: the flat namespace (Node CommonJS, bundlers) or a
10
+ * namespace that nests them under `default`.
11
+ *
12
+ * @throws if neither shape carries `SchemaEncoder` — signing with a half-loaded
13
+ * kit would fail later with a far less useful error.
14
+ */
15
+ export const resolveEasSdkExports = (namespace) => {
16
+ const flat = namespace;
9
17
  if (typeof flat.SchemaEncoder === 'function')
10
18
  return flat;
11
19
  if (flat.default && typeof flat.default.SchemaEncoder === 'function') {
12
20
  return flat.default;
13
21
  }
14
22
  throw new Error('[eas-builder] @ethereum-attestation-service/eas-sdk: cannot locate SchemaEncoder on namespace or default export');
15
- })();
23
+ };
24
+ const easSdkExports = resolveEasSdkExports(easSdkNamespace);
16
25
  const { Offchain, OffchainAttestationVersion, SchemaEncoder, Delegated } = easSdkExports;
17
26
  const ZERO_REF_UID = ('0x' + '0'.repeat(64));
18
27
  /**
@@ -73,7 +82,8 @@ export const attestationExpirationTime = (options = {}) => {
73
82
  * them. Both libraries produce the same EIP-712 bytes, so the cast is a
74
83
  * type-erasure boundary, not a semantic one.
75
84
  */
76
- const adaptViemAccountToEasSigner = (account) => ({
85
+ /** Adapt a viem account to the ethers-shaped signer the SDK expects. Exported for tests. */
86
+ export const adaptViemAccountToEasSigner = (account) => ({
77
87
  getAddress: async () => account.address,
78
88
  signTypedData: async (domain, types, value) => {
79
89
  // The EAS SDK passes the message types only (no EIP712Domain entry)
@@ -343,7 +353,7 @@ export const buildDelegatedEasAttestationRequestForChain = async (args) => build
343
353
  * by hand because pulling the gate file into `@provex/eas` would
344
354
  * create a dependency loop.
345
355
  */
346
- const easContractVersionForChain = (chainId) => {
356
+ export const easContractVersionForChain = (chainId) => {
347
357
  if (chainId === 1 || chainId === 11155111)
348
358
  return '0.26';
349
359
  return '1.4.0';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EAS_ADDRESSES_BY_CHAIN_ID, PROVEX_AGE_SCHEMA_UID_BY_CHAIN_ID, getAgeSchemaUidOrPlaceholder, getEasAddress, getSchemaUid, } from './eas.js';
4
+ const ZERO_BYTES32 = `0x${'0'.repeat(64)}`;
5
+ const withEnv = (name, value, run) => {
6
+ process.env[name] = value;
7
+ try {
8
+ run();
9
+ }
10
+ finally {
11
+ delete process.env[name];
12
+ }
13
+ };
14
+ describe('environment overrides', () => {
15
+ it('uses a well-formed EAS address override instead of the pinned address', () => {
16
+ // WHY: staging points at a temporary EAS deploy through this override.
17
+ const override = `0x${'77'.repeat(20)}`;
18
+ withEnv('EAS_ADDRESS_369_OVERRIDE', override, () => {
19
+ assert.equal(getEasAddress(369), override);
20
+ });
21
+ });
22
+ it('rejects a malformed address override rather than signing against it', () => {
23
+ // WHY: a typo in an override must fail loudly; a silent fallback would
24
+ // sign attestations for the wrong contract.
25
+ withEnv('EAS_ADDRESS_369_OVERRIDE', '0x1234', () => {
26
+ assert.throws(() => getEasAddress(369), /EAS_ADDRESS_369_OVERRIDE must be 0x-prefixed 20-byte hex/);
27
+ });
28
+ });
29
+ it('treats an empty override as unset', () => {
30
+ withEnv('EAS_ADDRESS_369_OVERRIDE', '', () => {
31
+ assert.equal(getEasAddress(369), EAS_ADDRESSES_BY_CHAIN_ID[369]);
32
+ });
33
+ });
34
+ it('rejects a malformed schema unique identifier override', () => {
35
+ withEnv('EAS_SCHEMA_UID_369_OVERRIDE', '0xabc', () => {
36
+ assert.throws(() => getSchemaUid(369), /EAS_SCHEMA_UID_369_OVERRIDE must be 0x-prefixed 32-byte hex/);
37
+ });
38
+ });
39
+ it('reads no overrides when the runtime has no process object (browser)', () => {
40
+ // WHY: the user interface bundle has no `process`; overrides must simply
41
+ // be absent there instead of throwing a ReferenceError.
42
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'process');
43
+ Object.defineProperty(globalThis, 'process', { value: undefined, configurable: true });
44
+ try {
45
+ assert.equal(getEasAddress(369), EAS_ADDRESSES_BY_CHAIN_ID[369]);
46
+ }
47
+ finally {
48
+ if (original)
49
+ Object.defineProperty(globalThis, 'process', original);
50
+ }
51
+ });
52
+ });
53
+ describe('getAgeSchemaUidOrPlaceholder', () => {
54
+ it('returns the pinned identifier on registered chains and zero elsewhere', () => {
55
+ assert.equal(getAgeSchemaUidOrPlaceholder(369), PROVEX_AGE_SCHEMA_UID_BY_CHAIN_ID[369]);
56
+ assert.equal(getAgeSchemaUidOrPlaceholder(999_999), ZERO_BYTES32);
57
+ });
58
+ });
59
+ describe('unpinned chain placeholders', () => {
60
+ it('throws with a deploy hint when a known chain has no pinned address yet', () => {
61
+ // WHY: a null placeholder must never be returned as an address; signing
62
+ // against it would bind attestations to a nonexistent contract.
63
+ const original = EAS_ADDRESSES_BY_CHAIN_ID[369];
64
+ EAS_ADDRESSES_BY_CHAIN_ID[369] = null;
65
+ try {
66
+ assert.throws(() => getEasAddress(369), /address for chainId 369 is not pinned/);
67
+ }
68
+ finally {
69
+ EAS_ADDRESSES_BY_CHAIN_ID[369] = original;
70
+ }
71
+ });
72
+ });
package/dist/eas.js CHANGED
@@ -362,4 +362,4 @@ export const EAS_MAINNET_ADDRESS = EAS_ADDRESSES_BY_CHAIN_ID[1];
362
362
  /** @deprecated use `getSchemaRegistryAddress(1)`. */
363
363
  export const EAS_SCHEMA_REGISTRY_MAINNET = SCHEMA_REGISTRY_ADDRESSES_BY_CHAIN_ID[1];
364
364
  /** @deprecated use `getSchemaUid(chainId)` (or the soft variant for UI). */
365
- export const PROVEX_IDENTITY_SCHEMA_UID = PROVEX_IDENTITY_SCHEMA_UID_BY_CHAIN_ID[1] ?? ZERO_BYTES32;
365
+ export const PROVEX_IDENTITY_SCHEMA_UID = PROVEX_IDENTITY_SCHEMA_UID_BY_CHAIN_ID[1];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
4
+ import { buildOffchainEasAttestation } from './eas-builder.js';
5
+ import { EAS_MAINNET_ADDRESS, PROVEX_IDENTITY_SCHEMA_UID } from './eas.js';
6
+ import { isOffchainAttestationShape, liftWireOffchainAttestation, recoverOffchainAttestationSigner, toWireOffchainAttestation, } from './offchain-verify.js';
7
+ const attester = privateKeyToAccount(generatePrivateKey());
8
+ const signedSample = () => buildOffchainEasAttestation({
9
+ claim: {
10
+ platformHash: `0x${'11'.repeat(32)}`,
11
+ handle: 'alice',
12
+ user: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
13
+ expiresAt: 1800000000n,
14
+ },
15
+ attester,
16
+ eas: EAS_MAINNET_ADDRESS,
17
+ schemaUid: PROVEX_IDENTITY_SCHEMA_UID,
18
+ chainId: 1,
19
+ });
20
+ const wireWith = async (patch) => {
21
+ const wire = JSON.parse(JSON.stringify(toWireOffchainAttestation(await signedSample())));
22
+ return { ...wire, message: { ...wire.message, ...patch } };
23
+ };
24
+ describe('liftWireOffchainAttestation rejects unusable payloads', () => {
25
+ it('accepts finite numbers and bigints for the time fields', async () => {
26
+ const lifted = liftWireOffchainAttestation(await wireWith({ time: 5, expirationTime: 7n }));
27
+ assert.equal(lifted?.message.time, 5n);
28
+ assert.equal(lifted?.message.expirationTime, 7n);
29
+ });
30
+ it('returns null for a non-numeric string, so garbage never becomes a deadline', async () => {
31
+ assert.equal(liftWireOffchainAttestation(await wireWith({ time: '12abc' })), null);
32
+ });
33
+ it('returns null for a non-finite number', async () => {
34
+ assert.equal(liftWireOffchainAttestation(await wireWith({ expirationTime: Number.NaN })), null);
35
+ });
36
+ it('returns null for a missing expiration, so a no-expiry replay is refused', async () => {
37
+ assert.equal(liftWireOffchainAttestation(await wireWith({ expirationTime: undefined })), null);
38
+ });
39
+ it('returns null when the payload is not attestation-shaped', () => {
40
+ assert.equal(liftWireOffchainAttestation({ uid: 'x' }), null);
41
+ });
42
+ });
43
+ describe('isOffchainAttestationShape', () => {
44
+ it('rejects null and non-objects before reading any key', () => {
45
+ assert.equal(isOffchainAttestationShape(null), false);
46
+ assert.equal(isOffchainAttestationShape('attestation'), false);
47
+ });
48
+ it('rejects an object missing the signature', async () => {
49
+ const { signature: _omitted, ...rest } = await signedSample();
50
+ assert.equal(isOffchainAttestationShape(rest), false);
51
+ });
52
+ });
53
+ describe('recoverOffchainAttestationSigner rejection paths', () => {
54
+ it('reports valid false when the recovered signer is not the expected attester', async () => {
55
+ // WHY: a genuine signature from the wrong key must not pass as the gate's.
56
+ const other = privateKeyToAccount(generatePrivateKey());
57
+ const check = await recoverOffchainAttestationSigner(await signedSample(), other.address);
58
+ assert.equal(check.valid, false);
59
+ assert.equal(check.signer?.toLowerCase(), attester.address.toLowerCase());
60
+ });
61
+ it('reports valid true when the expected attester matches', async () => {
62
+ const check = await recoverOffchainAttestationSigner(await signedSample(), attester.address);
63
+ assert.equal(check.valid, true);
64
+ });
65
+ it('returns valid false with no signer for an unrecoverable signature', async () => {
66
+ const signed = await signedSample();
67
+ const check = await recoverOffchainAttestationSigner({ ...signed, signature: '0x1234' });
68
+ assert.deepEqual(check, { valid: false, signer: null });
69
+ });
70
+ });
@@ -110,14 +110,9 @@ const parseBigintField = (raw) => {
110
110
  return raw;
111
111
  if (typeof raw === 'number' && Number.isFinite(raw))
112
112
  return BigInt(raw);
113
- if (typeof raw === 'string' && /^-?\d+$/.test(raw)) {
114
- try {
115
- return BigInt(raw);
116
- }
117
- catch {
118
- return null;
119
- }
120
- }
113
+ // The pattern guarantees `BigInt` parses the string, so no try/catch is needed.
114
+ if (typeof raw === 'string' && /^-?\d+$/.test(raw))
115
+ return BigInt(raw);
121
116
  return null;
122
117
  };
123
118
  /**
@@ -1,9 +1,12 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { readFileSync } from 'node:fs';
4
- import { dirname, resolve } from 'node:path';
4
+ import { dirname, resolve, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- const HERE = dirname(fileURLToPath(import.meta.url));
6
+ // Coverage runs execute the compiled tests from `dist`; the walker must still
7
+ // read the original `.ts` sources, which sit in the sibling `src` directory.
8
+ const COMPILED_DIRECTORY = dirname(fileURLToPath(import.meta.url));
9
+ const HERE = COMPILED_DIRECTORY.endsWith(`${sep}dist`) ? resolve(COMPILED_DIRECTORY, '..', 'src') : COMPILED_DIRECTORY;
7
10
  const EAS_SOFTWARE_DEVELOPMENT_KIT = '@ethereum-attestation-service/eas-sdk';
8
11
  /**
9
12
  * Strip type-only import statements (`import type { … } from '…'`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@provex/eas",
3
- "version": "1.11.0",
3
+ "version": "2.0.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -47,7 +47,8 @@
47
47
  "scripts": {
48
48
  "build": "tsc",
49
49
  "test": "node --import tsx --test 'src/**/*.test.ts'",
50
- "test:watch": "node --import tsx --test --watch 'src/**/*.test.ts'"
50
+ "test:watch": "node --import tsx --test --watch 'src/**/*.test.ts'",
51
+ "test:coverage": "tsc --sourceMap && node --test --test-concurrency=2 --experimental-test-coverage --test-coverage-exclude='**/*.test.*' --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 'dist/**/*.test.js'"
51
52
  },
52
53
  "files": [
53
54
  "dist",