Skip to content

Commit c746381

Browse files
committed
feat(attestations): implement attestationvalidator
Implements the AttestationValidator class to perform a deeper validation EAS attestations according to our spec. Attestation should at least contain a chainID and contractAddress for a chain and contract that we support. The tokenID in the attestaion should point to an hypercert claimID. To support these schemas, the SchemaValidator has been split into an Ajv and Zod validator. Additionally, utils/tokenIds.ts has been added to validate the value of a tokenID. Tests have been updated accordingly.
1 parent 0cf34db commit c746381

File tree

11 files changed

+582
-58
lines changed

11 files changed

+582
-58
lines changed

src/constants.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,41 +9,66 @@ export const DEFAULT_ENVIRONMENT: Environment = "production";
99

1010
// The APIs we expose
1111

12+
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
13+
1214
const ENDPOINTS: { [key: string]: string } = {
1315
test: "https://staging-api.hypercerts.org",
1416
production: "https://api.hypercerts.org",
1517
};
1618

19+
const SUPPORTED_EAS_SCHEMAS: { [key: string]: { [key: string]: string | boolean } } = {
20+
BASIC_EVALUATION: {
21+
uid: "0x2f4f575d5df78ac52e8b124c4c900ec4c540f1d44f5b8825fac0af5308c91449",
22+
schema:
23+
"uint256 chain_id,address contract_address,uint256 token_id,uint8 evaluate_basic,uint8 evaluate_work,uint8 evaluate_contributors,uint8 evaluate_properties,string comments,string[] tags",
24+
resolver: ZERO_ADDRESS,
25+
revocable: true,
26+
},
27+
CREATOR_FEED: {
28+
uid: "0x48e3e1be1e08084b408a7035ac889f2a840b440bbf10758d14fb722831a200c3",
29+
schema:
30+
"uint256 chain_id,address contract_address,uint256 token_id,string title,string description,string[] sources",
31+
resolver: ZERO_ADDRESS,
32+
revocable: false,
33+
},
34+
};
35+
1736
// These are the deployments we manage
1837
const DEPLOYMENTS: { [key in SupportedChainIds]: Deployment } = {
1938
10: {
2039
chainId: 10,
2140
addresses: deployments[10],
41+
easSchemas: SUPPORTED_EAS_SCHEMAS,
2242
isTestnet: false,
2343
} as const,
2444
42220: {
2545
chainId: 42220,
2646
addresses: deployments[42220],
47+
easSchemas: SUPPORTED_EAS_SCHEMAS,
2748
isTestnet: false,
2849
},
2950
8453: {
3051
chainId: 8453,
3152
addresses: deployments[8453],
53+
easSchemas: SUPPORTED_EAS_SCHEMAS,
3254
isTestnet: false,
3355
} as const,
3456
11155111: {
3557
chainId: 11155111,
3658
addresses: deployments[11155111],
59+
easSchemas: SUPPORTED_EAS_SCHEMAS,
3760
isTestnet: true,
3861
} as const,
3962
84532: {
4063
chainId: 84532,
4164
addresses: deployments[84532],
65+
easSchemas: SUPPORTED_EAS_SCHEMAS,
4266
isTestnet: true,
4367
} as const,
4468
42161: {
4569
chainId: 42161,
4670
addresses: deployments[42161],
71+
easSchemas: SUPPORTED_EAS_SCHEMAS,
4772
isTestnet: false,
4873
} as const,
4974
421614: {

src/types/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,14 @@ export type Contracts =
3939
| "StrategyHypercertFractionOffer";
4040

4141
/**
42-
* Represents a deployment of a contract on a specific network.
42+
* Represents the hypercerts deployments on a specific network.
4343
*/
4444
export type Deployment = {
4545
chainId: SupportedChainIds;
4646
/** The address of the deployed contract. */
4747
addresses: Partial<Record<Contracts, `0x${string}`>>;
4848
isTestnet: boolean;
49+
easSchemas?: { [key: string]: { [key: string]: string | boolean } };
4950
};
5051

5152
/**

src/utils/tokenIds.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// https://github.com/hypercerts-org/hypercerts/blob/7671d06762c929bc2890a31e5dc392f8a30065c6/contracts/test/foundry/protocol/Bitshifting.t.sol
2+
3+
/**
4+
* The maximum value that can be represented as an uint256.
5+
* @type {BigInt}
6+
*/
7+
const MAX = BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
8+
9+
/**
10+
* A mask that represents the base id of the token. It is created by shifting the maximum uint256 value left by 128 bits.
11+
* @type {BigInt}
12+
*/
13+
const TYPE_MASK = MAX << BigInt(128);
14+
15+
/**
16+
* A mask that represents the index of a non-fungible token. It is created by shifting the maximum uint256 value right by 128 bits.
17+
* @type {BigInt}
18+
*/
19+
const NF_INDEX_MASK = MAX >> BigInt(128);
20+
21+
/**
22+
* Checks if a token ID represents a base type token.
23+
*
24+
* A token ID is considered to represent a base type token if:
25+
* - The bitwise AND of the token ID and the TYPE_MASK equals the token ID.
26+
* - The bitwise AND of the token ID and the NF_INDEX_MASK equals 0.
27+
*
28+
* @param {BigInt} id - The token ID to check.
29+
* @returns {boolean} - Returns true if the token ID represents a base type token, false otherwise.
30+
*/
31+
const isBaseType = (id: bigint) => {
32+
return (id & TYPE_MASK) === id && (id & NF_INDEX_MASK) === BigInt(0);
33+
};
34+
35+
/**
36+
* Checks if a token ID represents a claim token.
37+
*
38+
* A token ID is considered to represent a claim token if it is not null and it represents a base type token.
39+
*
40+
* @param {BigInt} tokenId - The token ID to check. It can be undefined.
41+
* @returns {boolean} - Returns true if the token ID represents a claim token, false otherwise.
42+
*/
43+
export const isHypercertToken = (tokenId?: bigint) => {
44+
if (!tokenId) {
45+
return false;
46+
}
47+
return isBaseType(tokenId);
48+
};
49+
50+
/**
51+
* Gets the claim token ID from a given token ID.
52+
*
53+
* The claim token ID is obtained by applying the TYPE_MASK to the given token ID using the bitwise AND operator.
54+
* The result is logged to the console for debugging purposes.
55+
*
56+
* @param {BigInt} tokenId - The token ID to get the claim token ID from.
57+
* @returns {BigInt} - Returns the claim token ID.
58+
*/
59+
export const getHypercertTokenId = (tokenId: bigint) => {
60+
return tokenId & TYPE_MASK;
61+
};

src/validator/base/SchemaValidator.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
import Ajv, { Schema, ErrorObject } from "ajv";
21
import { IValidator, ValidationError, ValidationResult } from "../interfaces";
2+
import Ajv, { Schema as AjvSchema, ErrorObject } from "ajv";
3+
import { z } from "zod";
34

4-
export abstract class SchemaValidator<T> implements IValidator<T> {
5+
// Base interface for all validators
6+
export interface ISchemaValidator<T> extends IValidator<T> {
7+
validate(data: unknown): ValidationResult<T>;
8+
}
9+
10+
// AJV-based validator
11+
export abstract class AjvSchemaValidator<T> implements ISchemaValidator<T> {
512
protected ajv: Ajv;
6-
protected schema: Schema;
13+
protected schema: AjvSchema;
714

8-
constructor(schema: Schema, additionalSchemas: Schema[] = []) {
15+
constructor(schema: AjvSchema, additionalSchemas: AjvSchema[] = []) {
916
this.ajv = new Ajv({ allErrors: true });
10-
// Add any additional schemas first
1117
additionalSchemas.forEach((schema) => this.ajv.addSchema(schema));
1218
this.schema = schema;
1319
}
@@ -38,3 +44,38 @@ export abstract class SchemaValidator<T> implements IValidator<T> {
3844
}));
3945
}
4046
}
47+
48+
// Zod-based validator
49+
export abstract class ZodSchemaValidator<T> implements ISchemaValidator<T> {
50+
protected schema: z.ZodType<T>;
51+
52+
constructor(schema: z.ZodType<T>) {
53+
this.schema = schema;
54+
}
55+
56+
validate(data: unknown): ValidationResult<T> {
57+
const result = this.schema.safeParse(data);
58+
59+
if (!result.success) {
60+
return {
61+
isValid: false,
62+
errors: this.formatErrors(result.error),
63+
};
64+
}
65+
66+
return {
67+
isValid: true,
68+
data: result.data,
69+
errors: [],
70+
};
71+
}
72+
73+
protected formatErrors(error: z.ZodError): ValidationError[] {
74+
return error.issues.map((issue) => ({
75+
code: issue.code || "SCHEMA_VALIDATION_ERROR",
76+
message: issue.message,
77+
field: issue.path.join("."),
78+
details: issue,
79+
}));
80+
}
81+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { z } from "zod";
2+
import { DEPLOYMENTS } from "../../constants";
3+
import { ZodSchemaValidator } from "../base/SchemaValidator";
4+
import { isHypercertToken } from "src/utils/tokenIds";
5+
6+
const AttestationSchema = z
7+
.object({
8+
chain_id: z.coerce.bigint(),
9+
contract_address: z.string(),
10+
token_id: z.coerce.bigint(),
11+
})
12+
.passthrough()
13+
.refine(
14+
(data) => {
15+
return Number(data.chain_id) in DEPLOYMENTS;
16+
},
17+
(data) => ({
18+
code: "INVALID_CHAIN_ID",
19+
message: `Chain ID ${data.chain_id.toString()} is not supported`,
20+
path: ["chain_id"],
21+
}),
22+
)
23+
.refine(
24+
(data) => {
25+
const deployment = DEPLOYMENTS[Number(data.chain_id) as keyof typeof DEPLOYMENTS];
26+
if (!deployment?.addresses) {
27+
return false;
28+
}
29+
const knownAddresses = Object.values(deployment.addresses).map((addr) => addr.toLowerCase());
30+
return knownAddresses.includes(data.contract_address.toLowerCase());
31+
},
32+
(data) => ({
33+
code: "INVALID_CONTRACT_ADDRESS",
34+
message: `Contract address ${data.contract_address} is not deployed on chain ${data.chain_id.toString()}`,
35+
path: ["contract_address"],
36+
}),
37+
)
38+
.refine(
39+
(data) => {
40+
return isHypercertToken(data.token_id);
41+
},
42+
(data) => ({
43+
code: "INVALID_TOKEN_ID",
44+
message: `Token ID ${data.token_id.toString()} is not a valid hypercert token`,
45+
path: ["token_id"],
46+
}),
47+
);
48+
49+
type AttestationData = z.infer<typeof AttestationSchema>;
50+
51+
// Example raw attestation
52+
53+
// {
54+
// "uid": "0x4f923f7485e013d3c64b55268304c0773bb84d150b4289059c77af0e28aea3f6",
55+
// "data": "0x000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000822f17a9a5eecfd66dbaff7946a8071c265d1d0700000000000000000000000000009c0900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000b5a757a616c752032303233000000000000000000000000000000000000000000",
56+
// "time": 1727969021,
57+
// "refUID": "0x0000000000000000000000000000000000000000000000000000000000000000",
58+
// "schema": "0x2f4f575d5df78ac52e8b124c4c900ec4c540f1d44f5b8825fac0af5308c91449",
59+
// "attester": "0x676703E18b2d03Aa36d6A3124B4F58716dBf61dB",
60+
// "recipient": "0x0000000000000000000000000000000000000000",
61+
// "revocable": false,
62+
// "expirationTime": 0,
63+
// "revocationTime": 0
64+
// }
65+
66+
// Example decoded attestation data
67+
68+
// {
69+
// "tags": [
70+
// "Zuzalu 2023"
71+
// ],
72+
// "chain_id": 10,
73+
// "comments": "",
74+
// "token_id": 1.3592579146656887e+43,
75+
// "evaluate_work": 1,
76+
// "evaluate_basic": 1,
77+
// "contract_address": "0x822F17A9A5EeCFd66dBAFf7946a8071C265D1d07",
78+
// "evaluate_properties": 1,
79+
// "evaluate_contributors": 1
80+
// }
81+
82+
// Example raw attestation data
83+
84+
// {
85+
// "uid": "0xc6b717cfbf9df516c0cbdc670fdd7d098ae0a7d30b2fb2c1ff7bd15a822bf1f4",
86+
// "data": "0x0000000000000000000000000000000000000000000000000000000000aa36a7000000000000000000000000a16dfb32eb140a6f3f2ac68f41dad8c7e83c4941000000000000000000000000000002580000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000001e54657374696e67206164646974696f6e616c206174746573746174696f6e0000000000000000000000000000000000000000000000000000000000000000000877757575757575740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000003a7b2274797065223a2275726c222c22737263223a2268747470733a2f2f7078686572652e636f6d2f656e2f70686f746f2f31333833373237227d00000000000000000000000000000000000000000000000000000000000000000000000000b27b2274797065223a22696d6167652f6a706567222c226e616d65223a22676f61745f62726f776e5f616e696d616c5f6e61747572655f62696c6c795f676f61745f6d616d6d616c5f63726561747572655f686f726e732d3635363235322d313533313531373336392e6a7067222c22737263223a226261666b72656964676d613237367a326d756178717a79797467676979647437627a617073736479786b7333376737736f37377372347977776775227d0000000000000000000000000000",
87+
// "time": 1737648084,
88+
// "refUID": "0x0000000000000000000000000000000000000000000000000000000000000000",
89+
// "schema": "0x48e3e1be1e08084b408a7035ac889f2a840b440bbf10758d14fb722831a200c3",
90+
// "attester": "0xdf2C3dacE6F31e650FD03B8Ff72beE82Cb1C199A",
91+
// "recipient": "0x0000000000000000000000000000000000000000",
92+
// "revocable": false,
93+
// "expirationTime": 0,
94+
// "revocationTime": 0
95+
// }
96+
97+
// Example decoded attestation data
98+
99+
// {
100+
// "title": "Testing additional attestation",
101+
// "sources": [
102+
// "{\"type\":\"url\",\"src\":\"https://pxhere.com/en/photo/1383727\"}",
103+
// "{\"type\":\"image/jpeg\",\"name\":\"goat_brown_animal_nature_billy_goat_mammal_creature_horns-656252-1531517369.jpg\",\"src\":\"bafkreidgma276z2muaxqzyytggiydt7bzapssdyxks37g7so77sr4ywwgu\"}"
104+
// ],
105+
// "chain_id": 11155111,
106+
// "token_id": 2.0416942015256308e+41,
107+
// "description": "wuuuuuut",
108+
// "contract_address": "0xa16DFb32Eb140a6f3F2AC68f41dAd8c7e83C4941"
109+
// }
110+
111+
export class AttestationValidator extends ZodSchemaValidator<AttestationData> {
112+
constructor() {
113+
super(AttestationSchema);
114+
}
115+
}

src/validator/validators/MetadataValidator.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { HypercertClaimdata, HypercertMetadata } from "src/types/metadata";
2-
import { SchemaValidator } from "../base/SchemaValidator";
2+
import { AjvSchemaValidator } from "../base/SchemaValidator";
33
import claimDataSchema from "../../resources/schema/claimdata.json";
44
import metaDataSchema from "../../resources/schema/metadata.json";
55
import { PropertyValidator } from "./PropertyValidator";
66

7-
export class MetadataValidator extends SchemaValidator<HypercertMetadata> {
7+
export class MetadataValidator extends AjvSchemaValidator<HypercertMetadata> {
88
private propertyValidator: PropertyValidator;
99

1010
constructor() {
@@ -36,7 +36,7 @@ export class MetadataValidator extends SchemaValidator<HypercertMetadata> {
3636
}
3737
}
3838

39-
export class ClaimDataValidator extends SchemaValidator<HypercertClaimdata> {
39+
export class ClaimDataValidator extends AjvSchemaValidator<HypercertClaimdata> {
4040
constructor() {
4141
super(claimDataSchema);
4242
}

src/validator/validators/PropertyValidator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ValidationError } from "../interfaces";
2-
import { SchemaValidator } from "../base/SchemaValidator";
2+
import { AjvSchemaValidator } from "../base/SchemaValidator";
33
import { HypercertMetadata } from "src/types";
44
import metaDataSchema from "../../resources/schema/metadata.json";
55

@@ -65,7 +65,7 @@ class GeoJSONValidationStrategy implements PropertyValidationStrategy {
6565
}
6666
}
6767

68-
export class PropertyValidator extends SchemaValidator<PropertyValue> {
68+
export class PropertyValidator extends AjvSchemaValidator<PropertyValue> {
6969
private readonly validationStrategies: Record<string, PropertyValidationStrategy> = {
7070
geoJSON: new GeoJSONValidationStrategy(),
7171
};

test/utils/tokenIds.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expect, it, describe } from "vitest";
2+
3+
import { isHypercertToken, getHypercertTokenId } from "../../src/utils/tokenIds";
4+
5+
const claimTokenId = 340282366920938463463374607431768211456n;
6+
const fractionTokenId = 340282366920938463463374607431768211457n;
7+
8+
describe("isClaimTokenId", () => {
9+
it("should return true for a claim token id", () => {
10+
expect(isHypercertToken(claimTokenId)).toBe(true);
11+
});
12+
13+
it("should return false for a non-claim token id", () => {
14+
expect(isHypercertToken(fractionTokenId)).toBe(false);
15+
});
16+
});
17+
18+
describe("getClaimTokenId", () => {
19+
it("should return the claim token id", () => {
20+
expect(getHypercertTokenId(claimTokenId)).toBe(claimTokenId);
21+
});
22+
23+
it("should return the claim token id for a fraction token id", () => {
24+
expect(getHypercertTokenId(fractionTokenId)).toBe(claimTokenId);
25+
});
26+
});

0 commit comments

Comments
 (0)