Skip to content

Commit 8921cc3

Browse files
authored
Merge pull request #30 from hypercerts-org/feat/attestation_validator
Implement attestation validator
2 parents 0cf34db + c746381 commit 8921cc3

11 files changed

+582
-58
lines changed

Diff for: src/constants.ts

+25
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: {

Diff for: src/types/client.ts

+2-1
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
/**

Diff for: src/utils/tokenIds.ts

+61
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+
};

Diff for: src/validator/base/SchemaValidator.ts

+46-5
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+
}

Diff for: src/validator/validators/AttestationValidator.ts

+115
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+
}

Diff for: src/validator/validators/MetadataValidator.ts

+3-3
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
}

Diff for: src/validator/validators/PropertyValidator.ts

+2-2
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
};

Diff for: test/utils/tokenIds.test.ts

+26
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)