From c746381fb4a1bd92b581c0bf6beb969efdaf9458 Mon Sep 17 00:00:00 2001 From: bitbeckers Date: Mon, 3 Feb 2025 22:35:46 +0100 Subject: [PATCH] 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. --- src/constants.ts | 25 +++ src/types/client.ts | 3 +- src/utils/tokenIds.ts | 61 ++++++ src/validator/base/SchemaValidator.ts | 51 ++++- .../validators/AttestationValidator.ts | 115 +++++++++++ src/validator/validators/MetadataValidator.ts | 6 +- src/validator/validators/PropertyValidator.ts | 4 +- test/utils/tokenIds.test.ts | 26 +++ test/validator/base/SchemaValidator.test.ts | 152 ++++++++++---- .../validators/AttestationValidator.test.ts | 189 ++++++++++++++++++ vitest.config.ts | 8 +- 11 files changed, 582 insertions(+), 58 deletions(-) create mode 100644 src/utils/tokenIds.ts create mode 100644 src/validator/validators/AttestationValidator.ts create mode 100644 test/utils/tokenIds.test.ts create mode 100644 test/validator/validators/AttestationValidator.test.ts diff --git a/src/constants.ts b/src/constants.ts index 84b64f1..8219900 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,41 +9,66 @@ export const DEFAULT_ENVIRONMENT: Environment = "production"; // The APIs we expose +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + const ENDPOINTS: { [key: string]: string } = { test: "https://staging-api.hypercerts.org", production: "https://api.hypercerts.org", }; +const SUPPORTED_EAS_SCHEMAS: { [key: string]: { [key: string]: string | boolean } } = { + BASIC_EVALUATION: { + uid: "0x2f4f575d5df78ac52e8b124c4c900ec4c540f1d44f5b8825fac0af5308c91449", + schema: + "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", + resolver: ZERO_ADDRESS, + revocable: true, + }, + CREATOR_FEED: { + uid: "0x48e3e1be1e08084b408a7035ac889f2a840b440bbf10758d14fb722831a200c3", + schema: + "uint256 chain_id,address contract_address,uint256 token_id,string title,string description,string[] sources", + resolver: ZERO_ADDRESS, + revocable: false, + }, +}; + // These are the deployments we manage const DEPLOYMENTS: { [key in SupportedChainIds]: Deployment } = { 10: { chainId: 10, addresses: deployments[10], + easSchemas: SUPPORTED_EAS_SCHEMAS, isTestnet: false, } as const, 42220: { chainId: 42220, addresses: deployments[42220], + easSchemas: SUPPORTED_EAS_SCHEMAS, isTestnet: false, }, 8453: { chainId: 8453, addresses: deployments[8453], + easSchemas: SUPPORTED_EAS_SCHEMAS, isTestnet: false, } as const, 11155111: { chainId: 11155111, addresses: deployments[11155111], + easSchemas: SUPPORTED_EAS_SCHEMAS, isTestnet: true, } as const, 84532: { chainId: 84532, addresses: deployments[84532], + easSchemas: SUPPORTED_EAS_SCHEMAS, isTestnet: true, } as const, 42161: { chainId: 42161, addresses: deployments[42161], + easSchemas: SUPPORTED_EAS_SCHEMAS, isTestnet: false, } as const, 421614: { diff --git a/src/types/client.ts b/src/types/client.ts index 944941d..5135f82 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -39,13 +39,14 @@ export type Contracts = | "StrategyHypercertFractionOffer"; /** - * Represents a deployment of a contract on a specific network. + * Represents the hypercerts deployments on a specific network. */ export type Deployment = { chainId: SupportedChainIds; /** The address of the deployed contract. */ addresses: Partial>; isTestnet: boolean; + easSchemas?: { [key: string]: { [key: string]: string | boolean } }; }; /** diff --git a/src/utils/tokenIds.ts b/src/utils/tokenIds.ts new file mode 100644 index 0000000..224918f --- /dev/null +++ b/src/utils/tokenIds.ts @@ -0,0 +1,61 @@ +// https://github.com/hypercerts-org/hypercerts/blob/7671d06762c929bc2890a31e5dc392f8a30065c6/contracts/test/foundry/protocol/Bitshifting.t.sol + +/** + * The maximum value that can be represented as an uint256. + * @type {BigInt} + */ +const MAX = BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); + +/** + * A mask that represents the base id of the token. It is created by shifting the maximum uint256 value left by 128 bits. + * @type {BigInt} + */ +const TYPE_MASK = MAX << BigInt(128); + +/** + * A mask that represents the index of a non-fungible token. It is created by shifting the maximum uint256 value right by 128 bits. + * @type {BigInt} + */ +const NF_INDEX_MASK = MAX >> BigInt(128); + +/** + * Checks if a token ID represents a base type token. + * + * A token ID is considered to represent a base type token if: + * - The bitwise AND of the token ID and the TYPE_MASK equals the token ID. + * - The bitwise AND of the token ID and the NF_INDEX_MASK equals 0. + * + * @param {BigInt} id - The token ID to check. + * @returns {boolean} - Returns true if the token ID represents a base type token, false otherwise. + */ +const isBaseType = (id: bigint) => { + return (id & TYPE_MASK) === id && (id & NF_INDEX_MASK) === BigInt(0); +}; + +/** + * Checks if a token ID represents a claim token. + * + * A token ID is considered to represent a claim token if it is not null and it represents a base type token. + * + * @param {BigInt} tokenId - The token ID to check. It can be undefined. + * @returns {boolean} - Returns true if the token ID represents a claim token, false otherwise. + */ +export const isHypercertToken = (tokenId?: bigint) => { + if (!tokenId) { + return false; + } + return isBaseType(tokenId); +}; + +/** + * Gets the claim token ID from a given token ID. + * + * The claim token ID is obtained by applying the TYPE_MASK to the given token ID using the bitwise AND operator. + * The result is logged to the console for debugging purposes. + * + * @param {BigInt} tokenId - The token ID to get the claim token ID from. + * @returns {BigInt} - Returns the claim token ID. + */ +export const getHypercertTokenId = (tokenId: bigint) => { + return tokenId & TYPE_MASK; +}; diff --git a/src/validator/base/SchemaValidator.ts b/src/validator/base/SchemaValidator.ts index dfafd42..5b5bc87 100644 --- a/src/validator/base/SchemaValidator.ts +++ b/src/validator/base/SchemaValidator.ts @@ -1,13 +1,19 @@ -import Ajv, { Schema, ErrorObject } from "ajv"; import { IValidator, ValidationError, ValidationResult } from "../interfaces"; +import Ajv, { Schema as AjvSchema, ErrorObject } from "ajv"; +import { z } from "zod"; -export abstract class SchemaValidator implements IValidator { +// Base interface for all validators +export interface ISchemaValidator extends IValidator { + validate(data: unknown): ValidationResult; +} + +// AJV-based validator +export abstract class AjvSchemaValidator implements ISchemaValidator { protected ajv: Ajv; - protected schema: Schema; + protected schema: AjvSchema; - constructor(schema: Schema, additionalSchemas: Schema[] = []) { + constructor(schema: AjvSchema, additionalSchemas: AjvSchema[] = []) { this.ajv = new Ajv({ allErrors: true }); - // Add any additional schemas first additionalSchemas.forEach((schema) => this.ajv.addSchema(schema)); this.schema = schema; } @@ -38,3 +44,38 @@ export abstract class SchemaValidator implements IValidator { })); } } + +// Zod-based validator +export abstract class ZodSchemaValidator implements ISchemaValidator { + protected schema: z.ZodType; + + constructor(schema: z.ZodType) { + this.schema = schema; + } + + validate(data: unknown): ValidationResult { + const result = this.schema.safeParse(data); + + if (!result.success) { + return { + isValid: false, + errors: this.formatErrors(result.error), + }; + } + + return { + isValid: true, + data: result.data, + errors: [], + }; + } + + protected formatErrors(error: z.ZodError): ValidationError[] { + return error.issues.map((issue) => ({ + code: issue.code || "SCHEMA_VALIDATION_ERROR", + message: issue.message, + field: issue.path.join("."), + details: issue, + })); + } +} diff --git a/src/validator/validators/AttestationValidator.ts b/src/validator/validators/AttestationValidator.ts new file mode 100644 index 0000000..15c1f34 --- /dev/null +++ b/src/validator/validators/AttestationValidator.ts @@ -0,0 +1,115 @@ +import { z } from "zod"; +import { DEPLOYMENTS } from "../../constants"; +import { ZodSchemaValidator } from "../base/SchemaValidator"; +import { isHypercertToken } from "src/utils/tokenIds"; + +const AttestationSchema = z + .object({ + chain_id: z.coerce.bigint(), + contract_address: z.string(), + token_id: z.coerce.bigint(), + }) + .passthrough() + .refine( + (data) => { + return Number(data.chain_id) in DEPLOYMENTS; + }, + (data) => ({ + code: "INVALID_CHAIN_ID", + message: `Chain ID ${data.chain_id.toString()} is not supported`, + path: ["chain_id"], + }), + ) + .refine( + (data) => { + const deployment = DEPLOYMENTS[Number(data.chain_id) as keyof typeof DEPLOYMENTS]; + if (!deployment?.addresses) { + return false; + } + const knownAddresses = Object.values(deployment.addresses).map((addr) => addr.toLowerCase()); + return knownAddresses.includes(data.contract_address.toLowerCase()); + }, + (data) => ({ + code: "INVALID_CONTRACT_ADDRESS", + message: `Contract address ${data.contract_address} is not deployed on chain ${data.chain_id.toString()}`, + path: ["contract_address"], + }), + ) + .refine( + (data) => { + return isHypercertToken(data.token_id); + }, + (data) => ({ + code: "INVALID_TOKEN_ID", + message: `Token ID ${data.token_id.toString()} is not a valid hypercert token`, + path: ["token_id"], + }), + ); + +type AttestationData = z.infer; + +// Example raw attestation + +// { +// "uid": "0x4f923f7485e013d3c64b55268304c0773bb84d150b4289059c77af0e28aea3f6", +// "data": "0x000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000822f17a9a5eecfd66dbaff7946a8071c265d1d0700000000000000000000000000009c0900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000b5a757a616c752032303233000000000000000000000000000000000000000000", +// "time": 1727969021, +// "refUID": "0x0000000000000000000000000000000000000000000000000000000000000000", +// "schema": "0x2f4f575d5df78ac52e8b124c4c900ec4c540f1d44f5b8825fac0af5308c91449", +// "attester": "0x676703E18b2d03Aa36d6A3124B4F58716dBf61dB", +// "recipient": "0x0000000000000000000000000000000000000000", +// "revocable": false, +// "expirationTime": 0, +// "revocationTime": 0 +// } + +// Example decoded attestation data + +// { +// "tags": [ +// "Zuzalu 2023" +// ], +// "chain_id": 10, +// "comments": "", +// "token_id": 1.3592579146656887e+43, +// "evaluate_work": 1, +// "evaluate_basic": 1, +// "contract_address": "0x822F17A9A5EeCFd66dBAFf7946a8071C265D1d07", +// "evaluate_properties": 1, +// "evaluate_contributors": 1 +// } + +// Example raw attestation data + +// { +// "uid": "0xc6b717cfbf9df516c0cbdc670fdd7d098ae0a7d30b2fb2c1ff7bd15a822bf1f4", +// "data": "0x0000000000000000000000000000000000000000000000000000000000aa36a7000000000000000000000000a16dfb32eb140a6f3f2ac68f41dad8c7e83c4941000000000000000000000000000002580000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000001e54657374696e67206164646974696f6e616c206174746573746174696f6e0000000000000000000000000000000000000000000000000000000000000000000877757575757575740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000003a7b2274797065223a2275726c222c22737263223a2268747470733a2f2f7078686572652e636f6d2f656e2f70686f746f2f31333833373237227d00000000000000000000000000000000000000000000000000000000000000000000000000b27b2274797065223a22696d6167652f6a706567222c226e616d65223a22676f61745f62726f776e5f616e696d616c5f6e61747572655f62696c6c795f676f61745f6d616d6d616c5f63726561747572655f686f726e732d3635363235322d313533313531373336392e6a7067222c22737263223a226261666b72656964676d613237367a326d756178717a79797467676979647437627a617073736479786b7333376737736f37377372347977776775227d0000000000000000000000000000", +// "time": 1737648084, +// "refUID": "0x0000000000000000000000000000000000000000000000000000000000000000", +// "schema": "0x48e3e1be1e08084b408a7035ac889f2a840b440bbf10758d14fb722831a200c3", +// "attester": "0xdf2C3dacE6F31e650FD03B8Ff72beE82Cb1C199A", +// "recipient": "0x0000000000000000000000000000000000000000", +// "revocable": false, +// "expirationTime": 0, +// "revocationTime": 0 +// } + +// Example decoded attestation data + +// { +// "title": "Testing additional attestation", +// "sources": [ +// "{\"type\":\"url\",\"src\":\"https://pxhere.com/en/photo/1383727\"}", +// "{\"type\":\"image/jpeg\",\"name\":\"goat_brown_animal_nature_billy_goat_mammal_creature_horns-656252-1531517369.jpg\",\"src\":\"bafkreidgma276z2muaxqzyytggiydt7bzapssdyxks37g7so77sr4ywwgu\"}" +// ], +// "chain_id": 11155111, +// "token_id": 2.0416942015256308e+41, +// "description": "wuuuuuut", +// "contract_address": "0xa16DFb32Eb140a6f3F2AC68f41dAd8c7e83C4941" +// } + +export class AttestationValidator extends ZodSchemaValidator { + constructor() { + super(AttestationSchema); + } +} diff --git a/src/validator/validators/MetadataValidator.ts b/src/validator/validators/MetadataValidator.ts index d534ccb..1f94c14 100644 --- a/src/validator/validators/MetadataValidator.ts +++ b/src/validator/validators/MetadataValidator.ts @@ -1,10 +1,10 @@ import { HypercertClaimdata, HypercertMetadata } from "src/types/metadata"; -import { SchemaValidator } from "../base/SchemaValidator"; +import { AjvSchemaValidator } from "../base/SchemaValidator"; import claimDataSchema from "../../resources/schema/claimdata.json"; import metaDataSchema from "../../resources/schema/metadata.json"; import { PropertyValidator } from "./PropertyValidator"; -export class MetadataValidator extends SchemaValidator { +export class MetadataValidator extends AjvSchemaValidator { private propertyValidator: PropertyValidator; constructor() { @@ -36,7 +36,7 @@ export class MetadataValidator extends SchemaValidator { } } -export class ClaimDataValidator extends SchemaValidator { +export class ClaimDataValidator extends AjvSchemaValidator { constructor() { super(claimDataSchema); } diff --git a/src/validator/validators/PropertyValidator.ts b/src/validator/validators/PropertyValidator.ts index 4b6917a..ec85f06 100644 --- a/src/validator/validators/PropertyValidator.ts +++ b/src/validator/validators/PropertyValidator.ts @@ -1,5 +1,5 @@ import { ValidationError } from "../interfaces"; -import { SchemaValidator } from "../base/SchemaValidator"; +import { AjvSchemaValidator } from "../base/SchemaValidator"; import { HypercertMetadata } from "src/types"; import metaDataSchema from "../../resources/schema/metadata.json"; @@ -65,7 +65,7 @@ class GeoJSONValidationStrategy implements PropertyValidationStrategy { } } -export class PropertyValidator extends SchemaValidator { +export class PropertyValidator extends AjvSchemaValidator { private readonly validationStrategies: Record = { geoJSON: new GeoJSONValidationStrategy(), }; diff --git a/test/utils/tokenIds.test.ts b/test/utils/tokenIds.test.ts new file mode 100644 index 0000000..c7fecf3 --- /dev/null +++ b/test/utils/tokenIds.test.ts @@ -0,0 +1,26 @@ +import { expect, it, describe } from "vitest"; + +import { isHypercertToken, getHypercertTokenId } from "../../src/utils/tokenIds"; + +const claimTokenId = 340282366920938463463374607431768211456n; +const fractionTokenId = 340282366920938463463374607431768211457n; + +describe("isClaimTokenId", () => { + it("should return true for a claim token id", () => { + expect(isHypercertToken(claimTokenId)).toBe(true); + }); + + it("should return false for a non-claim token id", () => { + expect(isHypercertToken(fractionTokenId)).toBe(false); + }); +}); + +describe("getClaimTokenId", () => { + it("should return the claim token id", () => { + expect(getHypercertTokenId(claimTokenId)).toBe(claimTokenId); + }); + + it("should return the claim token id for a fraction token id", () => { + expect(getHypercertTokenId(fractionTokenId)).toBe(claimTokenId); + }); +}); diff --git a/test/validator/base/SchemaValidator.test.ts b/test/validator/base/SchemaValidator.test.ts index ff6728b..99d84d2 100644 --- a/test/validator/base/SchemaValidator.test.ts +++ b/test/validator/base/SchemaValidator.test.ts @@ -1,62 +1,128 @@ import { expect } from "chai"; import { Schema } from "ajv"; -import { SchemaValidator } from "../../../src/validator/base/SchemaValidator"; +import { AjvSchemaValidator, ZodSchemaValidator } from "../../../src/validator/base/SchemaValidator"; import { describe, it } from "vitest"; +import { z } from "zod"; -// Create a concrete test implementation -class TestValidator extends SchemaValidator { +// Create concrete test implementations from the abstract classes +class TestAjvValidator extends AjvSchemaValidator { constructor(schema: Schema, additionalSchemas: Schema[] = []) { super(schema, additionalSchemas); } } -describe("SchemaValidator", () => { - const simpleSchema: Schema = { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], - }; - - it("should validate valid data", () => { - const validator = new TestValidator(simpleSchema); - const result = validator.validate({ name: "Test", age: 25 }); - - expect(result.isValid).to.be.true; - expect(result.data).to.deep.equal({ name: "Test", age: 25 }); - expect(result.errors).to.be.empty; - }); - - it("should return errors for invalid data", () => { - const validator = new TestValidator(simpleSchema); - const result = validator.validate({ age: 25 }); - - expect(result.isValid).to.be.false; - expect(result.data).to.be.undefined; - expect(result.errors).to.have.lengthOf(1); - expect(result.errors[0].field).to.equal("name"); - }); +class TestZodValidator extends ZodSchemaValidator { + constructor(schema: z.ZodType) { + super(schema); + } +} - it("should handle additional schemas", () => { - const refSchema: Schema = { +describe("SchemaValidator", () => { + describe("AjvSchemaValidator", () => { + const simpleSchema: Schema = { type: "object", properties: { - type: { type: "string" }, + name: { type: "string" }, + age: { type: "number" }, }, + required: ["name"], }; - const mainSchema: Schema = { - type: "object", - properties: { - data: { $ref: "ref#" }, - }, - }; + it("should validate valid data", () => { + const validator = new TestAjvValidator(simpleSchema); + const result = validator.validate({ name: "Test", age: 25 }); + + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal({ name: "Test", age: 25 }); + expect(result.errors).to.be.empty; + }); + + it("should return errors for invalid data", () => { + const validator = new TestAjvValidator(simpleSchema); + const result = validator.validate({ age: 25 }); + + expect(result.isValid).to.be.false; + expect(result.data).to.be.undefined; + expect(result.errors?.[0].code).to.equal("SCHEMA_VALIDATION_ERROR"); + expect(result.errors?.[0].field).to.equal("name"); + }); + + it("should handle additional schemas", () => { + const refSchema: Schema = { + type: "object", + properties: { + type: { type: "string" }, + }, + }; + + const mainSchema: Schema = { + type: "object", + properties: { + data: { $ref: "ref#" }, + }, + }; + + const validator = new TestAjvValidator(mainSchema, [{ ...refSchema, $id: "ref" }]); + const result = validator.validate({ data: { type: "test" } }); + + expect(result.isValid).to.be.true; + }); + }); + + describe("ZodSchemaValidator", () => { + const simpleSchema = z + .object({ + name: z.string(), + age: z.number().optional(), + }) + .refine( + (data) => data.name === "Test", + (data) => ({ + message: "Custom error: name must be Test", + path: ["name"], + code: "CUSTOM_ERROR", + }), + ); + + it("should validate valid data", () => { + const validator = new TestZodValidator(simpleSchema); + const result = validator.validate({ name: "Test", age: 25 }); + + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal({ name: "Test", age: 25 }); + expect(result.errors).to.be.empty; + }); + + it("should return errors for invalid data", () => { + const validator = new TestZodValidator(simpleSchema); + const result = validator.validate({ age: 25 }); + + expect(result.isValid).to.be.false; + expect(result.data).to.be.undefined; + expect(result.errors?.[0].code).to.equal("invalid_type"); + expect(result.errors?.[0].field).to.equal("name"); + }); + + it("should preserve custom error codes from refinements", () => { + const validator = new TestZodValidator(simpleSchema); + const result = validator.validate({ name: "Incorrect" }); + + expect(result.isValid).to.be.false; + expect(result.errors?.[0].code).to.equal("CUSTOM_ERROR"); + }); + + it("should handle nested error paths", () => { + const nestedSchema = z.object({ + user: z.object({ + name: z.string(), + }), + }); - const validator = new TestValidator(mainSchema, [{ ...refSchema, $id: "ref" }]); - const result = validator.validate({ data: { type: "test" } }); + const validator = new TestZodValidator(nestedSchema); + const result = validator.validate({ user: { name: 123 } }); - expect(result.isValid).to.be.true; + expect(result.isValid).to.be.false; + expect(result.errors?.[0].field).to.equal("user.name"); + }); }); }); diff --git a/test/validator/validators/AttestationValidator.test.ts b/test/validator/validators/AttestationValidator.test.ts new file mode 100644 index 0000000..5222574 --- /dev/null +++ b/test/validator/validators/AttestationValidator.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from "vitest"; +import { AttestationValidator } from "../../../src/validator/validators/AttestationValidator"; +import { DEPLOYMENTS } from "../../../src/constants"; + +describe("AttestationValidator", () => { + const validator = new AttestationValidator(); + const validChainId = Object.keys(DEPLOYMENTS)[0]; + const validAddress = Object.values(DEPLOYMENTS[Number(validChainId) as keyof typeof DEPLOYMENTS].addresses)[0]; + // Using a valid hypercert token ID format + const validTokenId = BigInt("340282366920938463463374607431768211456"); + + describe("valid cases", () => { + it("accepts valid attestation with number chain_id", () => { + const result = validator.validate({ + chain_id: Number(validChainId), + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(true); + }); + + it("accepts valid attestation with string chain_id", () => { + const result = validator.validate({ + chain_id: validChainId, + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(true); + }); + + it("rejects valid attestation with hex string token_id", () => { + const result = validator.validate({ + chain_id: validChainId, + contract_address: validAddress, + token_id: "0x9c09000000000000000000000000000000", + }); + expect(result.isValid).toBe(false); + }); + }); + + describe("invalid cases", () => { + describe("chain_id validation", () => { + it("rejects non-numeric chain_id", () => { + const result = validator.validate({ + chain_id: "abc", + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].field).toBe("chain_id"); + }); + + it("rejects unknown chain_id", () => { + const result = validator.validate({ + chain_id: "999999", + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].code).toBe("INVALID_CHAIN_ID"); + }); + }); + + describe("contract_address validation", () => { + it("rejects invalid address format", () => { + const result = validator.validate({ + chain_id: validChainId, + contract_address: "not-an-address", + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].field).toBe("contract_address"); + }); + + it("rejects unknown contract address", () => { + const result = validator.validate({ + chain_id: validChainId, + contract_address: "0x1234567890123456789012345678901234567890", + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].code).toBe("INVALID_CONTRACT_ADDRESS"); + }); + }); + + describe("token_id validation", () => { + it("rejects non-hypercert token_id", () => { + const result = validator.validate({ + chain_id: validChainId, + contract_address: validAddress, + token_id: "123", + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].code).toBe("INVALID_TOKEN_ID"); + }); + + it("rejects non-numeric token_id", () => { + const result = validator.validate({ + chain_id: validChainId, + contract_address: validAddress, + token_id: "340282366920938463463374607431768211457", + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].field).toBe("token_id"); + }); + }); + + describe("missing fields", () => { + it("rejects missing chain_id", () => { + const result = validator.validate({ + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].field).toBe("chain_id"); + }); + + it("rejects missing contract_address", () => { + const result = validator.validate({ + chain_id: validChainId, + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].field).toBe("contract_address"); + }); + + it("rejects missing token_id", () => { + const result = validator.validate({ + chain_id: validChainId, + contract_address: validAddress, + }); + expect(result.isValid).toBe(false); + expect(result.errors?.[0].field).toBe("token_id"); + }); + }); + + describe("type coercion edge cases", () => { + it("rejects null values", () => { + const result = validator.validate({ + chain_id: null, + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + }); + + it("rejects undefined values", () => { + const result = validator.validate({ + chain_id: undefined, + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + }); + + it("rejects object values", () => { + const result = validator.validate({ + chain_id: {}, + contract_address: validAddress, + token_id: validTokenId, + }); + expect(result.isValid).toBe(false); + }); + }); + }); + + describe("Additional fields", () => { + const validData = { + chain_id: 10, + contract_address: "0x822F17A9A5EeCFd66dBAFf7946a8071C265D1d07", + token_id: BigInt("340282366920938463463374607431768211456"), + tags: ["Zuzalu 2023"], + comments: "", + evaluate_work: 1, + evaluate_basic: 1, + evaluate_properties: 1, + evaluate_contributors: 1, + }; + + it("should accept data with additional fields", () => { + const result = validator.validate(validData); + expect(result.isValid).toBe(true); + expect(result.data).toEqual({ + ...validData, + chain_id: BigInt(validData.chain_id), + }); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index cdbddc2..de40083 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,10 +10,10 @@ export default defineConfig({ // If you want a coverage reports even if your tests are failing, include the reportOnFailure option reportOnFailure: true, thresholds: { - lines: 77, - branches: 84, - functions: 76, - statements: 77, + lines: 78, + branches: 85, + functions: 78, + statements: 78, }, include: ["src/**/*.ts"], exclude: [