diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 1f5a484..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "lib/hypercerts-api"] - path = lib/hypercerts-api - url = http://github.com/hypercerts-org/hypercerts-api diff --git a/eslint.config.mjs b/eslint.config.mjs index 158f6de..a52ded6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,11 +1,14 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; - - -export default [ - { files: ["**/*.{js,mjs,cjs,ts}"] }, - { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, - pluginJs.configs.recommended, - ...tseslint.configs.recommended, -]; \ No newline at end of file +export default { + root: true, + env: { + browser: true, + node: true, + }, + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:chai-friendly/recommended"], + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint", "chai-friendly"], + rules: { + "no-unused-expressions": "off", + "chai-friendly/no-unused-expressions": "error", + }, +}; diff --git a/lib/hypercerts-api b/lib/hypercerts-api deleted file mode 160000 index 8c970ab..0000000 --- a/lib/hypercerts-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8c970abf10af5291b6e8137fc6be371d4d3cfe83 diff --git a/package.json b/package.json index f674684..7624c0d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "esbuild": "^0.19.8", "eslint": "^9.18.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-chai-friendly": "^1.0.1", "globals": "^15.14.0", "husky": "^9.1.7", "json-schema-to-typescript": "^13.1.1", @@ -70,7 +71,7 @@ }, "scripts": { "build": "pnpm types:json && pnpm codegen:api && rollup -c", - "codegen:api": "npx orval --input ./lib/hypercerts-api/src/__generated__/swagger.json --output ./src/__generated__/api.ts", + "codegen:api": "npx orval --input https://api.hypercerts.org/swagger.json --output ./src/__generated__/api.ts", "clean": "rm -rf ./dist", "prebuild": "pnpm clean", "prepack": "pnpm build", @@ -83,7 +84,7 @@ "commitlint": "commitlint --config commitlintrc.ts --edit" }, "lint-staged": { - "*.{js, jsx,ts,tsx}": [ + "*.{js,jsx,ts,tsx}": [ "eslint --quiet --fix" ], "*.{json,js,ts,jsx,tsx,html}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcd8133..777fba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@9.18.0(jiti@2.4.2)) + eslint-plugin-chai-friendly: + specifier: ^1.0.1 + version: 1.0.1(eslint@9.18.0(jiti@2.4.2)) globals: specifier: ^15.14.0 version: 15.14.0 @@ -2030,6 +2033,12 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-plugin-chai-friendly@1.0.1: + resolution: {integrity: sha512-dxD/uz1YKJ8U4yah1i+V/p/u+kHRy3YxTPe2nJGqb5lCR+ucan/KIexfZ5+q4X+tkllyMe86EBbAkdlwxNy3oQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + eslint: '>=3.0.0' + eslint-scope@8.2.0: resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6215,6 +6224,10 @@ snapshots: dependencies: eslint: 9.18.0(jiti@2.4.2) + eslint-plugin-chai-friendly@1.0.1(eslint@9.18.0(jiti@2.4.2)): + dependencies: + eslint: 9.18.0(jiti@2.4.2) + eslint-scope@8.2.0: dependencies: esrecurse: 4.3.0 diff --git a/src/__generated__/api.ts b/src/__generated__/api.ts index 738eadf..68bac42 100644 --- a/src/__generated__/api.ts +++ b/src/__generated__/api.ts @@ -10,6 +10,46 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios' +export type DeleteBlueprint200AnyOfSix = { + data?: unknown; + errors?: unknown; + message: string; + success: boolean; +}; + +export type DeleteBlueprint200AnyOfFourErrors = { + blueprint?: unknown; + signature: string; +}; + +export type DeleteBlueprint200AnyOfFour = { + data?: unknown; + errors: DeleteBlueprint200AnyOfFourErrors; + message: string; + success: boolean; +}; + +export type DeleteBlueprint200AnyOfTwoErrors = { + blueprint: string; + signature?: unknown; +}; + +export type DeleteBlueprint200AnyOfTwo = { + data?: unknown; + errors: DeleteBlueprint200AnyOfTwoErrors; + message: string; + success: boolean; +}; + +export type DeleteBlueprint200AnyOf = { + data: unknown; + errors: unknown; + message: string; + success: boolean; +}; + +export type DeleteBlueprint200 = DeleteBlueprint200AnyOf | DeleteBlueprint200AnyOfTwo | DeleteBlueprint200AnyOfFour | DeleteBlueprint200AnyOfSix; + export type DeleteHyperboardParams = { adminAddress: string; signature: string; @@ -108,37 +148,117 @@ export type StoreOrder201AnyOf = { export type StoreOrder201 = StoreOrder201AnyOf | StoreOrder201AnyOfTwo | StoreOrder201AnyOfFour; -/** - * Interface for validating an allow list dump. - */ +export type StoreMetadata201AnyOfFourErrors = { + metadata: string; +}; + +export type StoreMetadata201AnyOfFour = { + data?: unknown; + errors: StoreMetadata201AnyOfFourErrors; + message: string; + success: boolean; + valid?: unknown; +}; + +export type StoreMetadata201 = StoreMetadata201AnyOf | StoreMetadata201AnyOfTwo | StoreMetadata201AnyOfFour; + +export type StoreMetadata201AnyOfTwoData = { + cid: string; +}; + +export type StoreMetadata201AnyOfTwo = { + data: StoreMetadata201AnyOfTwoData; + errors?: unknown; + message?: unknown; + success: boolean; + valid?: unknown; +}; + +export type StoreMetadata201AnyOf = { + data?: unknown; + errors: unknown; + message: string; + success: boolean; + valid: boolean; +}; + +export type ProcessSignatureRequests200 = { + message: string; + success: boolean; +}; + +export type CancelSignatureRequest200 = { + message: string; + success: boolean; +}; + +export type UploadBody = { + /** - Array of files to upload (max 5 files, 10MB each) */ + files?: Blob[]; + /** - Optional JSON string with additional metadata */ + jsonData?: string; +}; + export interface ValidateAllowListRequest { allowList: string; totalUnits?: string; } -/** - * Interface for storing an allow list dump on IPFS - */ export interface StoreAllowListRequest { allowList: string; totalUnits?: string; } +export interface BlueprintQueueMintRequest { + chain_id: number; + minter_address: string; + signature: string; + tx_hash: string; +} + +export interface BlueprintDeleteRequest { + admin_address: string; + chain_id: number; + signature: string; +} + +export interface BlueprintCreateRequest { + admin_address: string; + chain_id: number; + form_values: unknown; + minter_address: string; + signature: string; +} + +export type BlueprintResponseData = { + blueprint_id: number; +}; + +export interface BlueprintResponse { + data?: BlueprintResponseData; + errors?: RecordStringStringOrStringArray; + message?: string; + success: boolean; +} + export type HyperboardUpdateRequestCollectionsItemHypercertsItem = { factor: number; hypercertId: string; }; +export type HyperboardUpdateRequestCollectionsItemBlueprintsItem = { + blueprintId: number; + factor: number; +}; + export type HyperboardUpdateRequestCollectionsItem = { + blueprints: HyperboardUpdateRequestCollectionsItemBlueprintsItem[]; description: string; hypercerts: HyperboardUpdateRequestCollectionsItemHypercertsItem[]; id?: string; title: string; }; -/** - * Interface for updating a hyperboard - */ export interface HyperboardUpdateRequest { adminAddress: string; backgroundImg?: string; @@ -155,16 +275,19 @@ export type HyperboardCreateRequestCollectionsItemHypercertsItem = { hypercertId: string; }; +export type HyperboardCreateRequestCollectionsItemBlueprintsItem = { + blueprintId: number; + factor: number; +}; + export type HyperboardCreateRequestCollectionsItem = { + blueprints: HyperboardCreateRequestCollectionsItemBlueprintsItem[]; description: string; hypercerts: HyperboardCreateRequestCollectionsItemHypercertsItem[]; id?: string; title: string; }; -/** - * Interface for creating a hyperboard - */ export interface HyperboardCreateRequest { adminAddress: string; backgroundImg?: string; @@ -175,31 +298,17 @@ export interface HyperboardCreateRequest { title: string; } -export type ApiResponseIdStringOrNullErrors = RecordStringStringOrStringArray | Error[]; - -/** - * @nullable - */ -export type ApiResponseIdStringOrNullData = { +export type HyperboardResponseData = { id: string; -} | null; +}; -/** - * Interface for a generic API response. - */ -export interface ApiResponseIdStringOrNull { - /** @nullable */ - data?: ApiResponseIdStringOrNullData; - errors?: ApiResponseIdStringOrNullErrors; +export interface HyperboardResponse { + data?: HyperboardResponseData; + errors?: RecordStringStringOrStringArray; message?: string; success: boolean; } -/** - * Response for a created hyperboard - */ -export type HyperboardCreateResponse = ApiResponseIdStringOrNull; - export interface ValidateOrderRequest { chainId: number; tokenIds: string[]; @@ -292,35 +401,25 @@ export const OrderValidatorCode = { NUMBER_902: 902, } as const; -/** - * Interface for validating metadata. - */ export interface ValidateMetadataRequest { metadata: HypercertMetadata; } -/** - * Interface for a validation response. - */ -export type ValidationResponse = ApiResponseValidationResult; - -export type ApiResponseValidationResultErrors = RecordStringStringOrStringArray | Error[]; - -/** - * Interface for a validation response. - */ -export interface ValidationResult { +export interface ValidationResponse { data?: unknown; errors?: RecordStringStringOrStringArray; + message?: string; + success: boolean; valid: boolean; } -/** - * Interface for a generic API response. - */ -export interface ApiResponseValidationResult { - data?: ValidationResult; - errors?: ApiResponseValidationResultErrors; +export type StorageResponseData = { + cid: string; +}; + +export interface StorageResponse { + data?: StorageResponseData; + errors?: RecordStringStringOrStringArray; message?: string; success: boolean; } @@ -341,7 +440,7 @@ export interface HypercertMetadata { description: string; /** An url pointing to the external website of the project */ external_url?: string; - hypercert?: HypercertClaimdata361; + hypercert?: HypercertClaimdata; /** A URI pointing to a resource with mime type image/* representing the asset to which this token represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive. */ image: string; /** Identifies the asset to which this token represents */ @@ -353,18 +452,12 @@ export interface HypercertMetadata { version?: string; } -/** - * Interface for storing metadata and allow list dump on IPFS. - */ export interface StoreMetadataWithAllowlistRequest { allowList: string; metadata: HypercertMetadata; totalUnits?: string; } -/** - * Interface for storing metadata on IPFS. - */ export interface StoreMetadataRequest { metadata: HypercertMetadata; } @@ -372,7 +465,7 @@ export interface StoreMetadataRequest { /** * Work time period. The value is UNIX time in seconds from epoch. */ -export type HypercertClaimdata361WorkTimeframe = { +export type HypercertClaimdataWorkTimeframe = { display_value?: string; name?: string; value?: number[]; @@ -382,7 +475,7 @@ export type HypercertClaimdata361WorkTimeframe = { /** * Scopes of work */ -export type HypercertClaimdata361WorkScope = { +export type HypercertClaimdataWorkScope = { display_value?: string; excludes?: string[]; name?: string; @@ -393,7 +486,7 @@ export type HypercertClaimdata361WorkScope = { /** * Rights */ -export type HypercertClaimdata361Rights = { +export type HypercertClaimdataRights = { display_value?: string; excludes?: string[]; name?: string; @@ -404,7 +497,7 @@ export type HypercertClaimdata361Rights = { /** * Impact time period. The value is UNIX time in seconds from epoch. */ -export type HypercertClaimdata361ImpactTimeframe = { +export type HypercertClaimdataImpactTimeframe = { display_value?: string; name?: string; value?: number[]; @@ -414,7 +507,7 @@ export type HypercertClaimdata361ImpactTimeframe = { /** * Scopes of impact */ -export type HypercertClaimdata361ImpactScope = { +export type HypercertClaimdataImpactScope = { display_value?: string; excludes?: string[]; name?: string; @@ -425,7 +518,7 @@ export type HypercertClaimdata361ImpactScope = { /** * Contributors */ -export type HypercertClaimdata361Contributors = { +export type HypercertClaimdataContributors = { display_value?: string; name?: string; value?: string[]; @@ -435,98 +528,126 @@ export type HypercertClaimdata361Contributors = { /** * Properties of an impact claim */ -export interface HypercertClaimdata361 { +export interface HypercertClaimdata { /** Contributors */ - contributors: HypercertClaimdata361Contributors; + contributors: HypercertClaimdataContributors; /** Scopes of impact */ - impact_scope: HypercertClaimdata361ImpactScope; + impact_scope: HypercertClaimdataImpactScope; /** Impact time period. The value is UNIX time in seconds from epoch. */ - impact_timeframe: HypercertClaimdata361ImpactTimeframe; + impact_timeframe: HypercertClaimdataImpactTimeframe; /** Rights */ - rights?: HypercertClaimdata361Rights; + rights?: HypercertClaimdataRights; /** Scopes of work */ - work_scope: HypercertClaimdata361WorkScope; + work_scope: HypercertClaimdataWorkScope; /** Work time period. The value is UNIX time in seconds from epoch. */ - work_timeframe: HypercertClaimdata361WorkTimeframe; + work_timeframe: HypercertClaimdataWorkTimeframe; [key: string]: unknown; } -/** - * Interface for a storage response. - */ -export type StorageResponse = ApiResponseCidString; - -export type ApiResponseCidStringErrors = RecordStringStringOrStringArray | Error[]; +export interface CancelSignatureRequest { + chain_id: number; + owner_address: string; + signature: string; +} -export type ApiResponseCidStringData = { +export type UploadResponseDataResultsItem = { cid: string; + fileName: string; +}; + +export type UploadResponseDataFailedItem = { + error: string; + fileName: string; +}; + +export type UploadResponseData = { + failed: UploadResponseDataFailedItem[]; + results: UploadResponseDataResultsItem[]; }; /** - * Interface for a generic API response. + * Construct a type with a set of properties K of type T */ -export interface ApiResponseCidString { - data?: ApiResponseCidStringData; - errors?: ApiResponseCidStringErrors; - message?: string; +export interface RecordStringString {[key: string]: string} + +export type UploadStatus = typeof UploadStatus[keyof typeof UploadStatus]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const UploadStatus = { + all: 'all', + some: 'some', + none: 'none', +} as const; + +export interface UploadResponse { + data?: UploadResponseData; + errors?: RecordStringString; + message: string; success: boolean; + uploadStatus: UploadStatus; } /** - * Interface for a user add or update request. */ -export interface AddOrUpdateUserRequest { - avatar: string; +export type MultisigUserUpsertRequestType = typeof MultisigUserUpsertRequestType[keyof typeof MultisigUserUpsertRequestType]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const MultisigUserUpsertRequestType = { + multisig: 'multisig', +} as const; + +export interface MultisigUserUpsertRequest { chain_id: number; - display_name: string; - signature: string; + messageHash: string; + /** */ + type: MultisigUserUpsertRequestType; } -export type ApiResponseErrors = RecordStringStringOrStringArray | Error[]; +export type AddOrUpdateUserRequest = EOAUserUpsertRequest | MultisigUserUpsertRequest; /** - * Interface for a generic API response. */ -export interface ApiResponse { - data?: unknown; - errors?: ApiResponseErrors; - message?: string; - success: boolean; -} +export type EOAUserUpsertRequestType = typeof EOAUserUpsertRequestType[keyof typeof EOAUserUpsertRequestType]; -export type ApiResponseAddressStringOrNullErrors = RecordStringStringOrStringArray | Error[]; -/** - * @nullable - */ -export type ApiResponseAddressStringOrNullData = { +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const EOAUserUpsertRequestType = { + eoa: 'eoa', +} as const; + +export interface EOAUserUpsertRequest { + avatar?: string; + chain_id: number; + display_name?: string; + signature: string; + /** */ + type: EOAUserUpsertRequestType; +} + +export type UserResponseData = { address: string; -} | null; +}; /** - * Interface for a generic API response. + * Construct a type with a set of properties K of type T */ -export interface ApiResponseAddressStringOrNull { - /** @nullable */ - data?: ApiResponseAddressStringOrNullData; - errors?: ApiResponseAddressStringOrNullErrors; +export interface RecordStringStringOrStringArray {[key: string]: string | string[]} + +export interface BaseResponse { + errors?: RecordStringStringOrStringArray; message?: string; success: boolean; } -export type AddOrUpdateUserResponse = ApiResponseAddressStringOrNull; - -export interface Error { - message: string; - name: string; - stack?: string; +export interface UserResponse { + data?: UserResponseData; + errors?: RecordStringStringOrStringArray; + message?: string; + success: boolean; } -/** - * Construct a type with a set of properties K of type T - */ -export interface RecordStringStringOrStringArray {[key: string]: string | string[]} - @@ -534,7 +655,7 @@ export interface RecordStringStringOrStringArray {[key: string]: string | string /** * Add or update a user */ -export const addOrUpdateUser = >( +export const addOrUpdateUser = >( address: string, addOrUpdateUserRequest: AddOrUpdateUserRequest, options?: AxiosRequestConfig ): Promise => { @@ -544,13 +665,52 @@ export const addOrUpdateUser = >( + uploadBody?: UploadBody, options?: AxiosRequestConfig + ): Promise => {const formData = new FormData(); +if(uploadBody?.files !== undefined) { + uploadBody.files.forEach(value => formData.append('files', value)); + } +if(uploadBody?.jsonData !== undefined) { + formData.append('jsonData', uploadBody.jsonData) + } + + return axios.post( + `/v1/upload`, + formData,options + ); + } + +export const cancelSignatureRequest = >( + safeAddress: string, + messageHash: string, + cancelSignatureRequest: CancelSignatureRequest, options?: AxiosRequestConfig + ): Promise => { + return axios.post( + `/v1/signature-requests/${safeAddress}-${messageHash}/cancel`, + cancelSignatureRequest,options + ); + } + +export const processSignatureRequests = >( + options?: AxiosRequestConfig + ): Promise => { + return axios.post( + `/v1/signature-requests/process`,undefined,options + ); + } + /** * Submits a new hypercert metadata object for validation and storage on IPFS. When an allowlist URI is provided the service will validate the allowlist data before storing the metadata. Note that this might lead to a race condition when uploading metadata and the allowlist separately in rapid succession. In that case we recommend using POST /metadata/with-allowlist instead. */ -export const storeMetadata = >( +export const storeMetadata = >( storeMetadataRequest: StoreMetadataRequest, options?: AxiosRequestConfig ): Promise => { return axios.post( @@ -565,7 +725,7 @@ The service will parse and validate the allow list data and the metadata. After successful validation, the allow list data will be uploaded to IPFS and the URI of the allowlist will be attached to the hypercert metadata. If an allow list URI is already present, the service will return an error. */ -export const storeMetadataWithAllowlist = >( +export const storeMetadataWithAllowlist = >( storeMetadataWithAllowlistRequest: StoreMetadataWithAllowlistRequest, options?: AxiosRequestConfig ): Promise => { return axios.post( @@ -577,7 +737,7 @@ export const storeMetadataWithAllowlist = >( +export const validateMetadata = >( validateMetadataRequest: ValidateMetadataRequest, options?: AxiosRequestConfig ): Promise => { return axios.post( @@ -589,7 +749,7 @@ export const validateMetadata = >( +export const validateMetadataWithAllowlist = >( storeMetadataWithAllowlistRequest: StoreMetadataWithAllowlistRequest, options?: AxiosRequestConfig ): Promise => { return axios.post( @@ -649,7 +809,7 @@ export const validateOrder = >( /** * Create a new hyperboard. Creates the collections passed to it automatically. */ -export const createHyperboard = >( +export const createHyperboard = >( hyperboardCreateRequest: HyperboardCreateRequest, options?: AxiosRequestConfig ): Promise => { return axios.post( @@ -658,7 +818,7 @@ export const createHyperboard = >( +export const updateHyperboard = >( hyperboardId: string, hyperboardUpdateRequest: HyperboardUpdateRequest, options?: AxiosRequestConfig ): Promise => { @@ -668,7 +828,7 @@ export const updateHyperboard = >( +export const deleteHyperboard = >( hyperboardId: string, params: DeleteHyperboardParams, options?: AxiosRequestConfig ): Promise => { @@ -679,13 +839,42 @@ export const deleteHyperboard = >( ); } +export const createBlueprint = >( + blueprintCreateRequest: BlueprintCreateRequest, options?: AxiosRequestConfig + ): Promise => { + return axios.post( + `/v1/blueprints`, + blueprintCreateRequest,options + ); + } + +export const deleteBlueprint = >( + blueprintId: number, + blueprintDeleteRequest: BlueprintDeleteRequest, options?: AxiosRequestConfig + ): Promise => { + return axios.delete( + `/v1/blueprints/${blueprintId}`,{data: + blueprintDeleteRequest, ...options} + ); + } + +export const mintBlueprint = >( + blueprintId: number, + blueprintQueueMintRequest: BlueprintQueueMintRequest, options?: AxiosRequestConfig + ): Promise => { + return axios.post( + `/v1/blueprints/mint/${blueprintId}`, + blueprintQueueMintRequest,options + ); + } + /** * Submits a new allowlist for validation and storage on IPFS. While we maintain a database of allowlists, the allowlist itself is stored on IPFS. Try to keep a backup of the allowlist for recovery purposes. Provide the dump of the OpenZeppelin MerkleTree and the total units. */ -export const storeAllowList = >( +export const storeAllowList = >( storeAllowListRequest: StoreAllowListRequest, options?: AxiosRequestConfig ): Promise => { return axios.post( @@ -699,7 +888,7 @@ export const storeAllowList = >( Provide the dump of the OpenZeppelin MerkleTree and the total units. */ -export const validateAllowList = >( +export const validateAllowList = >( validateAllowListRequest: ValidateAllowListRequest, options?: AxiosRequestConfig ): Promise => { return axios.post( @@ -708,17 +897,23 @@ export const validateAllowList = -export type StoreMetadataResult = AxiosResponse -export type StoreMetadataWithAllowlistResult = AxiosResponse -export type ValidateMetadataResult = AxiosResponse -export type ValidateMetadataWithAllowlistResult = AxiosResponse +export type AddOrUpdateUserResult = AxiosResponse +export type UploadResult = AxiosResponse +export type CancelSignatureRequestResult = AxiosResponse +export type ProcessSignatureRequestsResult = AxiosResponse +export type StoreMetadataResult = AxiosResponse +export type StoreMetadataWithAllowlistResult = AxiosResponse +export type ValidateMetadataResult = AxiosResponse +export type ValidateMetadataWithAllowlistResult = AxiosResponse export type StoreOrderResult = AxiosResponse export type DeleteOrderResult = AxiosResponse export type UpdateOrderNonceResult = AxiosResponse export type ValidateOrderResult = AxiosResponse -export type CreateHyperboardResult = AxiosResponse -export type UpdateHyperboardResult = AxiosResponse -export type DeleteHyperboardResult = AxiosResponse -export type StoreAllowListResult = AxiosResponse -export type ValidateAllowListResult = AxiosResponse +export type CreateHyperboardResult = AxiosResponse +export type UpdateHyperboardResult = AxiosResponse +export type DeleteHyperboardResult = AxiosResponse +export type CreateBlueprintResult = AxiosResponse +export type DeleteBlueprintResult = AxiosResponse +export type MintBlueprintResult = AxiosResponse +export type StoreAllowListResult = AxiosResponse +export type ValidateAllowListResult = AxiosResponse 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/resources/schema/metadata.json b/src/resources/schema/metadata.json index f1a6d96..7884d69 100644 --- a/src/resources/schema/metadata.json +++ b/src/resources/schema/metadata.json @@ -36,14 +36,26 @@ "type": "array", "items": { "type": "object", - "properties": { - "trait_type": { - "type": "string" + "oneOf": [ + { + "properties": { + "trait_type": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["trait_type", "value"], + "additionalProperties": false }, - "value": { - "type": "string" + { + "properties": { + "trait_type": { "type": "string" }, + "type": { "type": "string" }, + "src": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["trait_type", "type", "src", "name"], + "additionalProperties": false } - } + ] } }, "hypercert": { 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/types/metadata.d.ts b/src/types/metadata.d.ts index 295ca8b..9a67a57 100644 --- a/src/types/metadata.d.ts +++ b/src/types/metadata.d.ts @@ -37,11 +37,18 @@ export interface HypercertMetadata { * A CID pointer to the merke tree proof json on ipfs */ allowList?: string; - properties?: { - trait_type?: string; - value?: string; - [k: string]: unknown; - }[]; + properties?: ( + | { + trait_type: string; + value: string; + } + | { + trait_type: string; + type: string; + src: string; + name: string; + } + )[]; hypercert?: HypercertClaimdata; } /** diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts index f994a3a..75cd9ec 100644 --- a/src/utils/formatter.ts +++ b/src/utils/formatter.ts @@ -48,7 +48,10 @@ const formatHypercertData = ({ external_url?: string; image: string; version: string; - properties?: { trait_type: string; value?: string; [k: string]: unknown }[]; + properties?: ( + | { trait_type: string; value: string } + | { trait_type: string; type: string; src: string; name: string } + )[]; impactScope: string[]; excludedImpactScope: string[]; workScope: string[]; diff --git a/src/utils/index.ts b/src/utils/index.ts index a5aeb44..43d7425 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,6 +5,7 @@ import { logger } from "./logger"; import { handleSdkError, handleContractError } from "./errors"; import { getClaimStoredDataFromTxHash } from "./txParser"; import { parseClaimOrFractionId } from "./parsing"; +import { isHypercertToken, getHypercertTokenId } from "./tokenIds"; export { getProofsFromAllowlist, @@ -16,4 +17,6 @@ export { parseAllowListEntriesToMerkleTree, getClaimStoredDataFromTxHash, parseClaimOrFractionId, + isHypercertToken, + getHypercertTokenId, }; 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/ValidatorFactory.ts b/src/validator/ValidatorFactory.ts new file mode 100644 index 0000000..c22c391 --- /dev/null +++ b/src/validator/ValidatorFactory.ts @@ -0,0 +1,34 @@ +import { HypercertMetadata, HypercertClaimdata, AllowlistEntry } from "src/types"; +import { IValidator } from "./interfaces"; +import { MerkleProofData, MerkleProofValidator } from "./validators/MerkleProofValidator"; +import { MetadataValidator, ClaimDataValidator } from "./validators/MetadataValidator"; +import { AllowlistValidator } from "./validators/AllowListValidator"; +import { AllowlistValidationParams } from "./validators/AllowListValidator"; +import { PropertyValidator, PropertyValue } from "./validators/PropertyValidator"; +import { AttestationData, AttestationValidator } from "./validators/AttestationValidator"; + +export class ValidatorFactory { + static createMetadataValidator(): IValidator { + return new MetadataValidator(); + } + + static createClaimDataValidator(): IValidator { + return new ClaimDataValidator(); + } + + static createAllowlistValidator(): IValidator { + return new AllowlistValidator(); + } + + static createMerkleProofValidator(): IValidator { + return new MerkleProofValidator(); + } + + static createPropertyValidator(): IValidator { + return new PropertyValidator(); + } + + static createAttestationValidator(): IValidator { + return new AttestationValidator(); + } +} diff --git a/src/validator/base/SchemaValidator.ts b/src/validator/base/SchemaValidator.ts new file mode 100644 index 0000000..5b5bc87 --- /dev/null +++ b/src/validator/base/SchemaValidator.ts @@ -0,0 +1,81 @@ +import { IValidator, ValidationError, ValidationResult } from "../interfaces"; +import Ajv, { Schema as AjvSchema, ErrorObject } from "ajv"; +import { z } from "zod"; + +// 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: AjvSchema; + + constructor(schema: AjvSchema, additionalSchemas: AjvSchema[] = []) { + this.ajv = new Ajv({ allErrors: true }); + additionalSchemas.forEach((schema) => this.ajv.addSchema(schema)); + this.schema = schema; + } + + validate(data: unknown): ValidationResult { + const validate = this.ajv.compile(this.schema); + + if (!validate(data)) { + return { + isValid: false, + errors: this.formatErrors(validate.errors || []), + }; + } + + return { + isValid: true, + data: data as T, + errors: [], + }; + } + + protected formatErrors(errors: ErrorObject[]): ValidationError[] { + return errors.map((error) => ({ + code: "SCHEMA_VALIDATION_ERROR", + message: error.message || "Unknown validation error", + field: error.instancePath || (error.params.missingProperty as string) || "", + details: error.params, + })); + } +} + +// 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/index.ts b/src/validator/index.ts index 141ac01..a7289c3 100644 --- a/src/validator/index.ts +++ b/src/validator/index.ts @@ -1,17 +1,6 @@ -import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; -import Ajv from "ajv"; - -import claimDataSchema from "../resources/schema/claimdata.json"; -import evaluationSchema from "../resources/schema/evaluation.json"; -import metaDataSchema from "../resources/schema/metadata.json"; import { AllowlistEntry, HypercertClaimdata, HypercertMetadata, MintingError } from "../types"; -import { isAddress } from "viem"; - -//TODO replace with ZOD -const ajv = new Ajv({ allErrors: true }); // options can be passed, e.g. {allErrors: true} -ajv.addSchema(metaDataSchema, "metaData"); -ajv.addSchema(claimDataSchema, "claimData"); -ajv.addSchema(evaluationSchema, "evaluation.json"); +import { ValidationError } from "./interfaces"; +import { ValidatorFactory } from "./ValidatorFactory"; /** * Represents the result of a validation operation. @@ -26,139 +15,109 @@ type ValidationResult = { errors: Record; }; +/** + * Maps new validator errors to the legacy format + */ +const mapErrors = (errors: ValidationError[]): Record => { + return errors.reduce( + (acc, err) => ({ + ...acc, + [err.field?.replace("/", "") || err.code]: err.message, + }), + {}, + ); +}; + /** * Validates Hypercert metadata. * - * This function uses the AJV library to validate the metadata. It first retrieves the schema for the metadata, - * then validates the data against the schema. If the schema is not found, it returns an error. If the data does not - * conform to the schema, it returns the validation errors. If the data is valid, it returns a success message. + * Uses the AJV library to validate the metadata against its schema. If the data does not + * conform to the schema, it returns the validation errors. * - * @param {unknown} data - The metadata to validate. This should be an object that conforms to the HypercertMetadata type. - * @returns {ValidationResult} An object that includes a validity flag and any errors that occurred during validation. + * @param {unknown} data - The metadata to validate. Should conform to the HypercertMetadata type. + * @returns {ValidationResult} Object containing validity flag, validated data, and any validation errors. + * @deprecated use ValidatorFactory.createMetadataValidator() instead */ -const validateMetaData = (data: unknown): ValidationResult => { - const schemaName = "metaData"; - const validate = ajv.getSchema(schemaName); - if (!validate) { - return { data, valid: false, errors: { schema: "Schema not found" } }; - } - - if (!validate(data)) { - const errors: Record = {}; - for (const e of validate.errors || []) { - const key = e.params.missingProperty || "other"; - if (key && e.message) { - errors[key] = e.message; - } - } - return { data: data as unknown, valid: false, errors }; - } - - return { data: data as HypercertMetadata, valid: true, errors: {} }; +export const validateMetaData = (data: unknown): ValidationResult => { + const result = ValidatorFactory.createMetadataValidator().validate(data); + + console.log(result.errors); + return { + data: result.data || data, + valid: result.isValid, + errors: mapErrors(result.errors), + }; }; /** * Validates Hypercert claim data. * - * This function uses the AJV library to validate the claim data. It first retrieves the schema for the claim data, - * then validates the data against the schema. If the schema is not found, it returns an error. If the data does not - * conform to the schema, it returns the validation errors. If the data is valid, it returns a success message. + * Uses the AJV library to validate the claim data against its schema. If the data does not + * conform to the schema, it returns the validation errors. * - * @param {unknown} data - The claim data to validate. This should be an object that conforms to the HypercertClaimdata type. - * @returns {ValidationResult} An object that includes a validity flag and any errors that occurred during validation. + * @param {unknown} data - The claim data to validate. Should conform to the HypercertClaimdata type. + * @returns {ValidationResult} Object containing validity flag, validated data, and any validation errors. + * @deprecated use ValidatorFactory.createClaimDataValidator() instead */ -const validateClaimData = (data: unknown): ValidationResult => { - const schemaName = "claimData"; - const validate = ajv.getSchema(schemaName); - if (!validate) { - return { data, valid: false, errors: { schema: "Schema not found" } }; - } - - if (!validate(data)) { - const errors: Record = {}; - for (const e of validate.errors || []) { - const key = e.params.missingProperty || "other"; - if (key && e.message) { - errors[key] = e.message; - } - } - return { data: data as unknown, valid: false, errors }; - } - - return { data: data as HypercertClaimdata, valid: true, errors: {} }; +export const validateClaimData = (data: unknown): ValidationResult => { + const result = ValidatorFactory.createClaimDataValidator().validate(data); + return { + data: result.data || data, + valid: result.isValid, + errors: mapErrors(result.errors), + }; }; /** * Validates an array of allowlist entries. * - * This function checks that the total units in the allowlist match the expected total units, that the total units are greater than 0, - * and that all addresses in the allowlist are valid Ethereum addresses. It returns an object that includes a validity flag and any errors that occurred during validation. + * Checks that the total units match the expected total, units are greater than 0, + * and all addresses are valid Ethereum addresses. * - * @param {AllowlistEntry[]} data - The allowlist entries to validate. Each entry should be an object that includes an address and a number of units. + * @param {AllowlistEntry[]} data - The allowlist entries to validate. * @param {bigint} units - The expected total units in the allowlist. - * @returns {ValidationResult} An object that includes a validity flag and any errors that occurred during validation. The keys in the errors object are the names of the invalid properties, and the values are the error messages. + * @returns {ValidationResult} Object containing validity flag, validated data, and any validation errors. + * @deprecated use ValidatorFactory.createAllowlistValidator() instead */ -const validateAllowlist = (data: AllowlistEntry[], units: bigint): ValidationResult => { - const errors: Record = {}; - const totalUnits = data.reduce((acc, curr) => acc + BigInt(curr.units.toString()), 0n); - if (totalUnits != units) { - errors[ - "units" - ] = `Total units in allowlist must match total units [expected: ${units}, got: ${totalUnits.toString()}]`; - } - - if (totalUnits == 0n) { - errors["units"] = "Total units in allowlist must be greater than 0"; - } - - const filteredAddresses = data.filter((entry) => !isAddress(entry.address.toLowerCase())); - if (filteredAddresses.length > 0) { - errors["address"] = filteredAddresses.map((entry) => entry.address); - } - - if (Object.keys(errors).length > 0) { - return { data: data as unknown, valid: Object.keys(errors).length === 0, errors }; - } - - return { data: data as AllowlistEntry[], valid: Object.keys(errors).length === 0, errors }; +export const validateAllowlist = (data: AllowlistEntry[], units: bigint): ValidationResult => { + const result = ValidatorFactory.createAllowlistValidator().validate(data, { expectedUnits: units }); + return { + data: result.data || data, + valid: result.isValid, + errors: mapErrors(result.errors), + }; }; /** * Verifies a Merkle proof for a given root, signer address, units, and proof. * - * This function first checks if the signer address is a valid Ethereum address. If it's not, it throws a `MintingError`. - * It then verifies the Merkle proof using the `StandardMerkleTree.verify` method. If the verification fails, it throws a `MintingError`. - * * @param {string} root - The root of the Merkle tree. * @param {string} signerAddress - The signer's Ethereum address. * @param {bigint} units - The number of units. * @param {string[]} proof - The Merkle proof to verify. - * @throws {MintingError} Will throw a `MintingError` if the signer address is invalid or if the Merkle proof verification fails. + * @throws {MintingError} Will throw if the signer address is invalid or if the Merkle proof verification fails. + * @deprecated use ValidatorFactory.createMerkleProofValidator() instead */ -function verifyMerkleProof(root: string, signerAddress: string, units: bigint, proof: string[]): void { - if (!isAddress(signerAddress.toLowerCase())) { - throw new MintingError("Invalid address", { signerAddress }); - } +export const verifyMerkleProof = (root: string, signerAddress: string, units: bigint, proof: string[]): void => { + const validator = ValidatorFactory.createMerkleProofValidator(); + const result = validator.validate({ root, signerAddress, units, proof }); - const verified = StandardMerkleTree.verify(root, ["address", "uint256"], [signerAddress, units], proof); - if (!verified) { - throw new MintingError("Merkle proof verification failed", { root, proof }); + if (!result.isValid) { + throw new MintingError(result.errors[0].message, { root, proof }); } -} +}; /** * Verifies multiple Merkle proofs for given roots, a signer address, units, and proofs. * - * This function first checks if the lengths of the roots, units, and proofs arrays are equal. If they're not, it throws a `MintingError`. - * It then iterates over the arrays and verifies each Merkle proof using the `verifyMerkleProof` function. If any verification fails, it throws a `MintingError`. - * * @param {string[]} roots - The roots of the Merkle trees. * @param {string} signerAddress - The signer's Ethereum address. * @param {bigint[]} units - The numbers of units. * @param {string[][]} proofs - The Merkle proofs to verify. - * @throws {MintingError} Will throw a `MintingError` if the lengths of the input arrays are not equal or if any Merkle proof verification fails. + * @throws {MintingError} Will throw if input arrays have mismatched lengths or if any proof verification fails. + * @deprecated use ValidatorFactory.createMerkleProofValidator() instead */ -function verifyMerkleProofs(roots: string[], signerAddress: string, units: bigint[], proofs: string[][]) { +export const verifyMerkleProofs = (roots: string[], signerAddress: string, units: bigint[], proofs: string[][]) => { if (roots.length !== units.length || units.length !== proofs.length) { throw new MintingError("Invalid input", { roots, units, proofs }); } @@ -166,6 +125,6 @@ function verifyMerkleProofs(roots: string[], signerAddress: string, units: bigin for (let i = 0; i < roots.length; i++) { verifyMerkleProof(roots[i], signerAddress, units[i], proofs[i]); } -} +}; -export { validateMetaData, validateClaimData, validateAllowlist, verifyMerkleProof, verifyMerkleProofs }; +export { ValidatorFactory }; diff --git a/src/validator/interfaces.ts b/src/validator/interfaces.ts new file mode 100644 index 0000000..c6250d4 --- /dev/null +++ b/src/validator/interfaces.ts @@ -0,0 +1,16 @@ +export interface IValidator { + validate(data: unknown, params?: P): ValidationResult; +} + +export interface ValidationResult { + isValid: boolean; + data?: T; + errors: ValidationError[]; +} + +export interface ValidationError { + code: string; + message: string; + field?: string; + details?: unknown; +} diff --git a/src/validator/validators/AllowListValidator.ts b/src/validator/validators/AllowListValidator.ts new file mode 100644 index 0000000..9920219 --- /dev/null +++ b/src/validator/validators/AllowListValidator.ts @@ -0,0 +1,71 @@ +import { ValidationError, IValidator, ValidationResult } from "../interfaces"; +import { AllowlistEntry } from "src/types"; +import { isAddress } from "viem"; + +export interface AllowlistValidationParams { + expectedUnits: bigint; +} + +export class AllowlistValidator implements IValidator { + validate(data: unknown, params?: AllowlistValidationParams): ValidationResult { + if (!params?.expectedUnits) { + return { + isValid: false, + errors: [ + { + code: "MISSING_PARAMS", + message: "Expected units parameter is required", + }, + ], + }; + } + + if (!Array.isArray(data)) { + return { + isValid: false, + errors: [ + { + code: "INVALID_INPUT", + message: "Input must be an array", + }, + ], + }; + } + + const entries = data as AllowlistEntry[]; + const errors: ValidationError[] = []; + + // Validate total units + const totalUnits = entries.reduce((acc, curr) => acc + BigInt(curr.units.toString()), 0n); + + if (totalUnits !== params.expectedUnits) { + errors.push({ + code: "INVALID_TOTAL_UNITS", + message: `Total units in allowlist must match expected units`, + details: { + expected: params.expectedUnits.toString(), + actual: totalUnits.toString(), + }, + }); + } + + // Validate addresses + const invalidAddresses = entries + .filter((entry) => !isAddress(entry.address.toLowerCase())) + .map((entry) => entry.address); + + if (invalidAddresses.length > 0) { + errors.push({ + code: "INVALID_ADDRESSES", + message: "Invalid Ethereum addresses found in allowlist", + details: invalidAddresses, + }); + } + + return { + isValid: errors.length === 0, + data: errors.length === 0 ? data : undefined, + errors, + }; + } +} diff --git a/src/validator/validators/AttestationValidator.ts b/src/validator/validators/AttestationValidator.ts new file mode 100644 index 0000000..7a2c77a --- /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"], + }), + ); + +export 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/MerkleProofValidator.ts b/src/validator/validators/MerkleProofValidator.ts new file mode 100644 index 0000000..eadf8e4 --- /dev/null +++ b/src/validator/validators/MerkleProofValidator.ts @@ -0,0 +1,52 @@ +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; +import { IValidator, ValidationError, ValidationResult } from "../interfaces"; +import { isAddress } from "viem"; + +export interface MerkleProofData { + root: string; + signerAddress: string; + units: bigint; + proof: string[]; +} + +export class MerkleProofValidator implements IValidator { + validate(data: unknown): ValidationResult { + const proofData = data as MerkleProofData; + const errors: ValidationError[] = []; + + if (!isAddress(proofData.signerAddress)) { + errors.push({ + code: "INVALID_ADDRESS", + message: "Invalid signer address", + }); + } + + try { + const verified = StandardMerkleTree.verify( + proofData.root, + ["address", "uint256"], + [proofData.signerAddress, proofData.units], + proofData.proof, + ); + + if (!verified) { + errors.push({ + code: "INVALID_PROOF", + message: "Merkle proof verification failed", + }); + } + } catch (error) { + errors.push({ + code: "VERIFICATION_ERROR", + message: "Error during verification", + details: error, + }); + } + + return { + isValid: errors.length === 0, + data: errors.length === 0 ? proofData : undefined, + errors, + }; + } +} diff --git a/src/validator/validators/MetadataValidator.ts b/src/validator/validators/MetadataValidator.ts new file mode 100644 index 0000000..1f94c14 --- /dev/null +++ b/src/validator/validators/MetadataValidator.ts @@ -0,0 +1,43 @@ +import { HypercertClaimdata, HypercertMetadata } from "src/types/metadata"; +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 AjvSchemaValidator { + private propertyValidator: PropertyValidator; + + constructor() { + super(metaDataSchema, [claimDataSchema]); + this.propertyValidator = new PropertyValidator(); + } + + validate(data: unknown) { + const result = super.validate(data); + const errors = [...(result.errors || [])]; + + if (data) { + const metadata = data as HypercertMetadata; + if (metadata.properties?.length) { + const propertyErrors = metadata.properties + .map((property) => this.propertyValidator.validate(property)) + .filter((result) => !result.isValid) + .flatMap((result) => result.errors); + + errors.push(...propertyErrors); + } + } + + return { + isValid: errors.length === 0, + data: errors.length === 0 ? result.data : undefined, + errors, + }; + } +} + +export class ClaimDataValidator extends AjvSchemaValidator { + constructor() { + super(claimDataSchema); + } +} diff --git a/src/validator/validators/PropertyValidator.ts b/src/validator/validators/PropertyValidator.ts new file mode 100644 index 0000000..e4bc536 --- /dev/null +++ b/src/validator/validators/PropertyValidator.ts @@ -0,0 +1,100 @@ +import { ValidationError } from "../interfaces"; +import { AjvSchemaValidator } from "../base/SchemaValidator"; +import { HypercertMetadata } from "src/types"; +import metaDataSchema from "../../resources/schema/metadata.json"; + +export type PropertyValues = HypercertMetadata["properties"]; +export type PropertyValue = NonNullable[number]; + +interface PropertyValidationStrategy { + validate(property: NonNullable): ValidationError[]; +} + +interface GeoJSONProperty { + trait_type: string; + type: string; + src: string; + name: string; +} + +class GeoJSONValidationStrategy implements PropertyValidationStrategy { + private readonly MIME_TYPE = "application/geo+json"; + + validate(property: NonNullable): ValidationError[] { + if (!this.isGeoJSONProperty(property)) { + return [ + { + field: "type", + code: "missing_type", + message: "GeoJSON property must have type field", + }, + ]; + } + + const errors: ValidationError[] = []; + + if (property.type !== this.MIME_TYPE) { + errors.push({ + field: "type", + code: "invalid_mime_type", + message: `GeoJSON type must be ${this.MIME_TYPE}`, + }); + } + + if (!property.src?.startsWith("ipfs://") && !property.src?.startsWith("https://")) { + errors.push({ + field: "src", + code: "invalid_url", + message: "GeoJSON src must start with ipfs:// or https://", + }); + } + + if (!property.name?.endsWith(".geojson")) { + errors.push({ + field: "name", + code: "invalid_file_extension", + message: "GeoJSON name must end with .geojson", + }); + } + + return errors; + } + + private isGeoJSONProperty(property: any): property is GeoJSONProperty { + return "type" in property && "src" in property && "name" in property; + } +} + +export class PropertyValidator extends AjvSchemaValidator { + private readonly validationStrategies: Record = { + geoJSON: new GeoJSONValidationStrategy(), + }; + + constructor() { + super(metaDataSchema.properties.properties.items); + } + + validate(data: unknown) { + const result = super.validate(data); + + if (!result.isValid || !result.data) { + return result; + } + + const property = result.data as NonNullable; + const strategy = this.validationStrategies[property.trait_type]; + + if (strategy) { + const errors = strategy.validate(property); + if (errors.length > 0) { + return { + isValid: false, + data: undefined, + errors, + }; + } + } + + return result; + } +} diff --git a/test/utils/formatter.test.ts b/test/utils/formatter.test.ts index f492d5c..ac0b621 100644 --- a/test/utils/formatter.test.ts +++ b/test/utils/formatter.test.ts @@ -17,7 +17,16 @@ const testData: Partial = { workScope: ["test work scope"], workTimeframeStart: Math.floor(new Date().getTime()) / 1000, workTimeframeEnd: Math.floor(new Date().getTime()) / 1000, - properties: [{ trait_type: "test trait type", value: "aaa" }], + properties: [ + { trait_type: "test trait type", value: "aaa" }, + { trait_type: "test trait type", type: "image", src: "https://example.com", name: "test name" }, + { + trait_type: "geoJSON", + type: "application/geo+json", + name: "oceanus-conservation-barangay.geojson", + src: "ipfs://bafkreifhfoozmhdtjn2y4xqtosuf26mxr2sxzvrrprkucglgotx3ok362a", + }, + ], rights: ["test right 1", "test right 2"], version: "0.0.1", }; @@ -30,6 +39,9 @@ describe("Format Hypercert Data test", () => { it("checks correct metadata and returns result", () => { const result = formatHypercertData(testData as TestDataType); + expect(result.valid).to.be.true; + expect(result.data).to.not.be.null; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const validKeys = Object.keys(result.data!); 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.test.ts b/test/validator.test.ts index 296e49d..5f86873 100644 --- a/test/validator.test.ts +++ b/test/validator.test.ts @@ -8,8 +8,9 @@ import { mockDataSets } from "./helpers"; describe("Validate claim test", () => { const { hypercertData, hypercertMetadata } = mockDataSets; - it("checking default metadata", () => { + it("checking default metadata", (): void => { const result = validateMetaData(hypercertMetadata.data); + console.log(result); expect(result.valid).to.be.true; const invalidResult = validateMetaData({} as HypercertMetadata); diff --git a/test/validator/base/SchemaValidator.test.ts b/test/validator/base/SchemaValidator.test.ts new file mode 100644 index 0000000..99d84d2 --- /dev/null +++ b/test/validator/base/SchemaValidator.test.ts @@ -0,0 +1,128 @@ +import { expect } from "chai"; +import { Schema } from "ajv"; +import { AjvSchemaValidator, ZodSchemaValidator } from "../../../src/validator/base/SchemaValidator"; +import { describe, it } from "vitest"; +import { z } from "zod"; + +// Create concrete test implementations from the abstract classes +class TestAjvValidator extends AjvSchemaValidator { + constructor(schema: Schema, additionalSchemas: Schema[] = []) { + super(schema, additionalSchemas); + } +} + +class TestZodValidator extends ZodSchemaValidator { + constructor(schema: z.ZodType) { + super(schema); + } +} + +describe("SchemaValidator", () => { + describe("AjvSchemaValidator", () => { + const simpleSchema: Schema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }; + + 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 TestZodValidator(nestedSchema); + const result = validator.validate({ user: { name: 123 } }); + + expect(result.isValid).to.be.false; + expect(result.errors?.[0].field).to.equal("user.name"); + }); + }); +}); diff --git a/test/validator/validators/AllowListValidator.test.ts b/test/validator/validators/AllowListValidator.test.ts new file mode 100644 index 0000000..48499bf --- /dev/null +++ b/test/validator/validators/AllowListValidator.test.ts @@ -0,0 +1,62 @@ +import { expect } from "chai"; +import { AllowlistValidator } from "../../../src/validator/validators/AllowListValidator"; +import { AllowlistEntry } from "../../../src/types"; +import { describe, it } from "vitest"; + +describe("AllowlistValidator", () => { + const validator = new AllowlistValidator(); + + const validAddress = "0x1234567890123456789012345678901234567890"; + const invalidAddress = "0xinvalid"; + + it("should validate a valid allowlist", () => { + const allowlist: AllowlistEntry[] = [ + { address: validAddress, units: 500n }, + { address: "0x2234567890123456789012345678901234567890", units: 500n }, + ]; + + const result = validator.validate(allowlist, { expectedUnits: 1000n }); + + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal(allowlist); + expect(result.errors).to.be.empty; + }); + + it("should require expectedUnits parameter", () => { + const allowlist: AllowlistEntry[] = [{ address: validAddress, units: 1000n }]; + + const result = validator.validate(allowlist); + + expect(result.isValid).to.be.false; + expect(result.errors[0].code).to.equal("MISSING_PARAMS"); + }); + + it("should validate total units match expected units", () => { + const allowlist: AllowlistEntry[] = [{ address: validAddress, units: 500n }]; + + const result = validator.validate(allowlist, { expectedUnits: 1000n }); + + expect(result.isValid).to.be.false; + expect(result.errors[0].code).to.equal("INVALID_TOTAL_UNITS"); + }); + + it("should validate ethereum addresses", () => { + const allowlist: AllowlistEntry[] = [ + { address: invalidAddress, units: 500n }, + { address: validAddress, units: 500n }, + ]; + + const result = validator.validate(allowlist, { expectedUnits: 1000n }); + + expect(result.isValid).to.be.false; + expect(result.errors[0].code).to.equal("INVALID_ADDRESSES"); + expect(result.errors[0].details).to.include(invalidAddress); + }); + + it("should validate input is an array", () => { + const result = validator.validate({}, { expectedUnits: 1000n }); + + expect(result.isValid).to.be.false; + expect(result.errors[0].code).to.equal("INVALID_INPUT"); + }); +}); 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/test/validator/validators/MerkleProofValidator.test.ts b/test/validator/validators/MerkleProofValidator.test.ts new file mode 100644 index 0000000..5f1b359 --- /dev/null +++ b/test/validator/validators/MerkleProofValidator.test.ts @@ -0,0 +1,65 @@ +import { expect } from "chai"; +import { MerkleProofValidator } from "../../../src/validator/validators/MerkleProofValidator"; +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; +import { describe, it } from "vitest"; + +describe("MerkleProofValidator", () => { + const validator = new MerkleProofValidator(); + const validAddress = "0x1234567890123456789012345678901234567890"; + + // Create a real merkle tree for testing + const values = [ + [validAddress, 1000n], + ["0x2234567890123456789012345678901234567890", 2000n], + ]; + const tree = StandardMerkleTree.of(values, ["address", "uint256"]); + const proof = tree.getProof(0); // Get proof for first entry + + it("should validate a valid merkle proof", () => { + const result = validator.validate({ + root: tree.root, + signerAddress: validAddress, + units: 1000n, + proof, + }); + + expect(result.isValid).to.be.true; + expect(result.errors).to.be.empty; + }); + + it("should validate ethereum address", () => { + const result = validator.validate({ + root: tree.root, + signerAddress: "0xinvalid", + units: 1000n, + proof, + }); + + expect(result.isValid).to.be.false; + expect(result.errors[0].code).to.equal("INVALID_ADDRESS"); + }); + + it("should validate merkle proof verification", () => { + const result = validator.validate({ + root: tree.root, + signerAddress: validAddress, + units: 2000n, // Wrong units + proof, + }); + + expect(result.isValid).to.be.false; + expect(result.errors[0].code).to.equal("INVALID_PROOF"); + }); + + it("should handle verification errors", () => { + const result = validator.validate({ + root: "invalid_root", + signerAddress: validAddress, + units: 1000n, + proof, + }); + + expect(result.isValid).to.be.false; + expect(result.errors[0].code).to.equal("VERIFICATION_ERROR"); + }); +}); diff --git a/test/validator/validators/MetadataValidator.test.ts b/test/validator/validators/MetadataValidator.test.ts new file mode 100644 index 0000000..f49b7e3 --- /dev/null +++ b/test/validator/validators/MetadataValidator.test.ts @@ -0,0 +1,269 @@ +import { expect } from "chai"; +import { MetadataValidator, ClaimDataValidator } from "../../../src/validator/validators/MetadataValidator"; +import { HypercertMetadata, HypercertClaimdata } from "../../../src/types"; +import { describe, it } from "vitest"; + +describe("MetadataValidator", () => { + const validator = new MetadataValidator(); + + const validClaimData: HypercertClaimdata = { + impact_scope: { + name: "Impact Scope", + value: ["global"], + display_value: "Global", + }, + work_scope: { + name: "Work Scope", + value: ["research"], + display_value: "Research", + }, + work_timeframe: { + name: "Work Timeframe", + value: [1672531200, 1704067200], // 2023 + display_value: "2023", + }, + impact_timeframe: { + name: "Impact Timeframe", + value: [1672531200, 1704067200], // 2023 + display_value: "2023", + }, + contributors: { + name: "Contributors", + value: ["0x1234567890123456789012345678901234567890"], + display_value: "Contributor 1", + }, + }; + + const validMetadata: HypercertMetadata = { + name: "Test Hypercert", + description: "Test Description", + image: "ipfs://test", + version: "0.0.1", + ref: "ref", + hypercert: validClaimData, + }; + + describe("Basic Metadata Validation", () => { + it("should validate valid metadata", () => { + const result = validator.validate(validMetadata); + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal(validMetadata); + expect(result.errors).to.be.empty; + }); + + it("should validate required fields", () => { + const invalidMetadata = { + description: "Test Description", + image: "ipfs://test", + }; + + const result = validator.validate(invalidMetadata); + expect(result.isValid).to.be.false; + expect(result.errors[0].field).to.equal("name"); + }); + }); + + describe("Property Validation", () => { + it("should validate metadata with valid properties", () => { + const metadataWithProperties = { + ...validMetadata, + properties: [ + { + trait_type: "category", + value: "education", + }, + { + trait_type: "geoJSON", + type: "application/geo+json", + src: "ipfs://QmExample", + name: "location.geojson", + }, + ], + }; + + const result = validator.validate(metadataWithProperties); + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal(metadataWithProperties); + }); + + it("should reject metadata with invalid simple property", () => { + const metadataWithInvalidProperty = { + ...validMetadata, + properties: [ + { + trait_type: "category", + // missing required 'value' field + }, + ], + }; + + const result = validator.validate(metadataWithInvalidProperty); + expect(result.isValid).to.be.false; + expect(result.errors).to.have.length.greaterThan(0); + }); + + it("should reject metadata with invalid geoJSON property", () => { + const metadataWithInvalidGeoJSON = { + ...validMetadata, + properties: [ + { + trait_type: "geoJSON", + type: "wrong/type", + src: "invalid://QmExample", + name: "location.wrong", + }, + ], + }; + + const result = validator.validate(metadataWithInvalidGeoJSON); + expect(result.isValid).to.be.false; + expect(result.errors).to.have.length(3); // MIME type, URL, and file extension errors + }); + + it("should collect all property validation errors", () => { + const metadataWithMultipleInvalidProperties = { + ...validMetadata, + properties: [ + { + trait_type: "category", + // missing value + }, + { + trait_type: "geoJSON", + type: "wrong/type", + src: "invalid://QmExample", + name: "location.wrong", + }, + ], + }; + + const result = validator.validate(metadataWithMultipleInvalidProperties); + expect(result.isValid).to.be.false; + expect(result.errors.length).to.be.greaterThan(3); // Schema error plus GeoJSON errors + }); + + it("should handle empty properties array", () => { + const metadataWithEmptyProperties = { + ...validMetadata, + properties: [], + }; + + const result = validator.validate(metadataWithEmptyProperties); + expect(result.isValid).to.be.true; + }); + }); + + describe("Combined Validation", () => { + it("should validate metadata with both valid properties and claim data", () => { + const completeMetadata = { + ...validMetadata, + properties: [ + { + trait_type: "category", + value: "education", + }, + { + trait_type: "geoJSON", + type: "application/geo+json", + src: "ipfs://QmExample", + name: "location.geojson", + }, + ], + }; + + const result = validator.validate(completeMetadata); + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal(completeMetadata); + }); + + it("should collect errors from both metadata and property validation", () => { + const invalidMetadata = { + description: "Test Description", // missing required name + image: "ipfs://test", + properties: [ + { + trait_type: "geoJSON", + type: "wrong/type", + src: "invalid://QmExample", + name: "location.wrong", + }, + ], + }; + + const result = validator.validate(invalidMetadata); + + console.log(result.errors); + + expect(result.isValid).to.be.false; + expect(result.errors).to.have.length.greaterThan(3); // Schema errors plus property errors + }); + }); +}); + +describe("ClaimDataValidator", () => { + const validator = new ClaimDataValidator(); + + const validClaimData: HypercertClaimdata = { + impact_scope: { + name: "Impact Scope", + value: ["global"], + display_value: "Global", + }, + work_scope: { + name: "Work Scope", + value: ["research"], + display_value: "Research", + }, + work_timeframe: { + name: "Work Timeframe", + value: [1672531200, 1704067200], // 2023 + display_value: "2023", + }, + impact_timeframe: { + name: "Impact Timeframe", + value: [1672531200, 1704067200], // 2023 + display_value: "2023", + }, + contributors: { + name: "Contributors", + value: ["0x1234567890123456789012345678901234567890"], + display_value: "Contributor 1", + }, + }; + + it("should validate valid claim data", () => { + const result = validator.validate(validClaimData); + + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal(validClaimData); + expect(result.errors).to.be.empty; + }); + + it("should validate required fields", () => { + const invalidClaimData = { + impact_scope: validClaimData.impact_scope, + work_scope: validClaimData.work_scope, + // missing required fields + }; + + const result = validator.validate(invalidClaimData); + + expect(result.isValid).to.be.false; + expect(result.errors).to.have.length.greaterThan(0); + }); + + it("should validate array values", () => { + const invalidClaimData = { + ...validClaimData, + impact_scope: { + ...validClaimData.impact_scope, + value: "not an array", // should be an array + }, + }; + + const result = validator.validate(invalidClaimData); + + expect(result.isValid).to.be.false; + expect(result.errors[0].field).to.include("impact_scope"); + }); +}); diff --git a/test/validator/validators/PropertyValidator.test.ts b/test/validator/validators/PropertyValidator.test.ts new file mode 100644 index 0000000..39ca36e --- /dev/null +++ b/test/validator/validators/PropertyValidator.test.ts @@ -0,0 +1,138 @@ +import { expect } from "chai"; +import { describe, it } from "vitest"; +import { PropertyValidator } from "../../../src/validator/validators/PropertyValidator"; + +describe("PropertyValidator", () => { + const validator = new PropertyValidator(); + + describe("Basic Property Validation", () => { + it("should validate a simple property with trait_type and value", () => { + const property = { + trait_type: "category", + value: "education", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal(property); + }); + + it("should reject property with missing required fields", () => { + const property = { + trait_type: "category", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.false; + expect(result.errors).to.have.length.greaterThan(0); + }); + }); + + describe("GeoJSON Property Validation", () => { + it("should validate a valid geoJSON property", () => { + const property = { + trait_type: "geoJSON", + type: "application/geo+json", + src: "ipfs://QmExample", + name: "location.geojson", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.true; + expect(result.data).to.deep.equal(property); + }); + + it("should accept HTTPS source", () => { + const property = { + trait_type: "geoJSON", + type: "application/geo+json", + src: "https://example.com/location.geojson", + name: "location.geojson", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.true; + }); + + it("should reject invalid MIME type", () => { + const property = { + trait_type: "geoJSON", + type: "wrong/type", + src: "ipfs://QmExample", + name: "location.geojson", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.false; + expect(result.errors).to.deep.include({ + field: "type", + code: "invalid_mime_type", + message: "GeoJSON type must be application/geo+json", + }); + }); + + it("should reject invalid source URL", () => { + const property = { + trait_type: "geoJSON", + type: "application/geo+json", + src: "invalid://QmExample", + name: "location.geojson", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.false; + expect(result.errors).to.deep.include({ + field: "src", + code: "invalid_url", + message: "GeoJSON src must start with ipfs:// or https://", + }); + }); + + it("should reject invalid file extension", () => { + const property = { + trait_type: "geoJSON", + type: "application/geo+json", + src: "ipfs://QmExample", + name: "location.wrong", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.false; + expect(result.errors).to.deep.include({ + field: "name", + code: "invalid_file_extension", + message: "GeoJSON name must end with .geojson", + }); + }); + + it("should collect multiple validation errors", () => { + const property = { + trait_type: "geoJSON", + type: "wrong/type", + src: "invalid://QmExample", + name: "location.wrong", + }; + + const result = validator.validate(property); + expect(result.isValid).to.be.false; + expect(result.errors).to.have.length(3); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined input", () => { + const result = validator.validate(undefined); + expect(result.isValid).to.be.false; + }); + + it("should handle null input", () => { + const result = validator.validate(null); + expect(result.isValid).to.be.false; + }); + + it("should handle empty object", () => { + const result = validator.validate({}); + expect(result.isValid).to.be.false; + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 03bb5ce..8ba3a6c 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: 67, - branches: 80, - functions: 62, - statements: 67, + lines: 78, + branches: 85, + functions: 77, + statements: 78, }, include: ["src/**/*.ts"], exclude: [ @@ -21,7 +21,7 @@ export default defineConfig({ "**/*.types.ts", "**/types.ts", "src/indexer/*.ts", - "lib/*", + "src/__generated__/*.ts", ], }, },