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: [