From ff5c2940712f1bb303d19f8feace8c32b0ce047c Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 8 Apr 2024 12:54:52 +0100 Subject: [PATCH] Fix up verification functions in server package (#1132) * Fix up verification functions in server package * TODO question / comment * Make blockNumber mandatory and store storage under single key * Update comments * Tests for storage * Shorten setter * add jsdom to test environment * Add some common block functions and default block time for when we're running in instant seal mode * dont require block number when sending dev events * Move test configs to individual packages * build:typechain * package-lock.json && ignore next-env.d.ts when adding licenses * Add licenses * Copy test env file to root of repo for GHA * change expected dir for .env file * Fix some more package directory stuff * add missing env files * Add missing env file * Use zod to parse local storage! * remove --if-present so that we error on missing test command * add some more missing test commands * add some more missing test commands * Remove error from message * add two more missing test commands * Add test command for procaptcha * remove duplicate command * Package lock * fix type * fix storage * Fix test config * remove old test command --- demos/provider-mock/src/start.ts | 2 +- package-lock.json | 66 ------- packages/api/src/api/ProviderApi.ts | 4 +- packages/cli/src/start.ts | 2 +- packages/contract/src/contract/block.ts | 31 ++++ packages/contract/src/contract/helpers.ts | 20 +- packages/contract/src/contract/index.ts | 1 + packages/contract/src/contract/interface.ts | 5 +- packages/procaptcha/package.json | 4 +- packages/procaptcha/src/modules/Manager.ts | 31 ++-- .../src/modules/ProsopoCaptchaApi.ts | 1 - packages/procaptcha/src/modules/storage.ts | 37 ++-- .../src/tests/modules/storage.test.ts | 35 ++++ packages/procaptcha/vite.test.config.ts | 36 ++++ packages/provider/src/api/authMiddleware.ts | 3 +- packages/provider/src/api/captcha.ts | 16 +- packages/provider/src/tasks/tasks.ts | 24 +-- .../provider/src/tests/tasks/tasks.test.ts | 4 +- packages/server/src/server.ts | 171 +++++++++++------- packages/types/src/api/api.ts | 1 + packages/types/src/procaptcha/api.ts | 2 +- packages/types/src/procaptcha/index.ts | 1 + packages/types/src/procaptcha/storage.ts | 22 +++ packages/types/src/provider/api.ts | 1 + 24 files changed, 296 insertions(+), 224 deletions(-) create mode 100644 packages/contract/src/contract/block.ts create mode 100644 packages/procaptcha/src/tests/modules/storage.test.ts create mode 100644 packages/procaptcha/vite.test.config.ts create mode 100644 packages/types/src/procaptcha/storage.ts diff --git a/demos/provider-mock/src/start.ts b/demos/provider-mock/src/start.ts index 4b40fdcfeb..6bbfa52099 100644 --- a/demos/provider-mock/src/start.ts +++ b/demos/provider-mock/src/start.ts @@ -27,8 +27,8 @@ async function startApi() { apiApp.use(express.json()) apiApp.use(i18nMiddleware({})) apiApp.use(prosopoRouter()) - apiApp.use(handleErrors) + apiApp.listen(apiPort, () => { logger.info(`Prosopo app listening at http://localhost:${apiPort}`) }) diff --git a/package-lock.json b/package-lock.json index 4a089a93b8..1f92eb34b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9490,15 +9490,6 @@ "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", "dev": true }, - "node_modules/@types/morgan": { - "version": "1.9.9", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.9.tgz", - "integrity": "sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "20.11.29", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.29.tgz", @@ -11706,22 +11697,6 @@ } ] }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -22744,45 +22719,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/morgan": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", - "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/morgan/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/morgan/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -31979,14 +31915,12 @@ "cors": "^2.8.5", "cron-parser": "^4.9.0", "dotenv": "^16.0.1", - "morgan": "^1.10.0", "yargs": "^17.7.2", "zod": "^3.22.4" }, "devDependencies": { "@prosopo/config": "0.3.5", "@types/cors": "^2.8.14", - "@types/morgan": "^1.9.9", "es-main": "^1.2.0", "express": "^4.18.2", "tslib": "2.6.2", diff --git a/packages/api/src/api/ProviderApi.ts b/packages/api/src/api/ProviderApi.ts index 9b5c766117..b98b769f86 100644 --- a/packages/api/src/api/ProviderApi.ts +++ b/packages/api/src/api/ProviderApi.ts @@ -76,15 +76,17 @@ export default class ProviderApi extends HttpClientBase implements ProviderApi { public verifyDappUser( dapp: AccountId, userAccount: AccountId, + blockNumber: number, commitmentId?: string, maxVerifiedTime?: number ): Promise { const payload: { [ApiParams.dapp]: AccountId [ApiParams.user]: AccountId + [ApiParams.blockNumber]: number [ApiParams.commitmentId]?: string [ApiParams.maxVerifiedTime]?: number - } = { dapp: dapp, user: userAccount } + } = { dapp: dapp, user: userAccount, blockNumber } if (commitmentId) { payload['commitmentId'] = commitmentId } diff --git a/packages/cli/src/start.ts b/packages/cli/src/start.ts index 070c1e6bb3..1905b08fb1 100644 --- a/packages/cli/src/start.ts +++ b/packages/cli/src/start.ts @@ -34,7 +34,7 @@ export const handleErrors = ( try { message = JSON.parse(err.message) } catch { - console.debug('Invalid JSON error message') + console.error(err) } return response.status(code).json({ message, diff --git a/packages/contract/src/contract/block.ts b/packages/contract/src/contract/block.ts new file mode 100644 index 0000000000..7b3bfec3df --- /dev/null +++ b/packages/contract/src/contract/block.ts @@ -0,0 +1,31 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { ApiPromise } from '@polkadot/api/promise/Api' +import { BN } from '@polkadot/util/bn' + +/** + * Get the current block time in milliseconds + */ +export const getBlockTimeMs = (api: ApiPromise): number => { + const babe = api.consts.babe + const blockTime = babe ? babe.expectedBlockTime : new BN(6000) + return blockTime.toNumber() +} + +/** + * Get the current block number + */ +export const getCurrentBlockNumber = async (api: ApiPromise): Promise => { + return (await api.rpc.chain.getBlock()).block.header.number.toNumber() +} diff --git a/packages/contract/src/contract/helpers.ts b/packages/contract/src/contract/helpers.ts index 251460d212..030ce62628 100644 --- a/packages/contract/src/contract/helpers.ts +++ b/packages/contract/src/contract/helpers.ts @@ -13,21 +13,11 @@ // limitations under the License. import { Abi } from '@polkadot/api-contract/Abi' import { AbiMessage, ContractCallOutcome, ContractOptions, DecodedEvent } from '@polkadot/api-contract/types' -import { - AccountId, - BlockNumber, - DispatchError, - Event, - EventRecord, - StorageDeposit, - WeightV2, -} from '@polkadot/types/interfaces' +import { AccountId, DispatchError, Event, EventRecord, StorageDeposit, WeightV2 } from '@polkadot/types/interfaces' import { AnyJson } from '@polkadot/types/types/codec' import { ApiBase } from '@polkadot/api/types' -import { ApiPromise } from '@polkadot/api/promise/Api' import { BN, BN_ONE, BN_ZERO, bnFromHex } from '@polkadot/util/bn' import { Bytes } from '@polkadot/types-codec/extended' -import { Compact } from '@polkadot/types-codec/base' import { ContractSubmittableResult } from '@polkadot/api-contract/base/Contract' import { Logger, ProsopoContractError, capitaliseFirstLetter } from '@prosopo/common' import { Registry } from '@polkadot/types-codec/types/registry' @@ -255,11 +245,3 @@ export function formatEvent(event: Event): string { 'docs' in event ? (Array.isArray(event.docs) ? `(${event.docs.join('')})` : event.docs || '') : '' }` } - -export function getExpectedBlockTime(api: ApiPromise): BN { - return new BN(api.consts.babe?.expectedBlockTime || 6000) -} - -export async function getBlockNumber(api: ApiPromise): Promise> { - return (await api.rpc.chain.getBlock()).block.header.number -} diff --git a/packages/contract/src/contract/index.ts b/packages/contract/src/contract/index.ts index 71318f0309..0f5625f42f 100644 --- a/packages/contract/src/contract/index.ts +++ b/packages/contract/src/contract/index.ts @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +export * from './block.js' export * from './interface.js' export * from './helpers.js' export * from './useWeight.js' diff --git a/packages/contract/src/contract/interface.ts b/packages/contract/src/contract/interface.ts index 478bbaab27..f4ef6307f7 100644 --- a/packages/contract/src/contract/interface.ts +++ b/packages/contract/src/contract/interface.ts @@ -24,8 +24,9 @@ import { default as Methods } from '@prosopo/captcha-contract/mixed-methods' import { default as Query } from '@prosopo/captcha-contract/query' import { QueryReturnType, Result } from '@prosopo/typechain-types' import { SubmittableExtrinsic } from '@polkadot/api/promise/types' -import { encodeStringArgs, getExpectedBlockTime, getOptions, handleContractCallOutcomeErrors } from './helpers.js' +import { encodeStringArgs, getOptions, handleContractCallOutcomeErrors } from './helpers.js' import { firstValueFrom } from 'rxjs' +import { getBlockTimeMs } from './block.js' import { getPrimitiveStorageFields, getPrimitiveStorageValue, @@ -157,7 +158,7 @@ export class ProsopoCaptchaContract extends Contract implements IProsopoCaptchaC // Always query first as errors are passed back from a dry run but not from a transaction const message = this.abi.findMessage(contractMethodName) const encodedArgs: Uint8Array[] = encodeStringArgs(this.abi, message, args) - const expectedBlockTime = getExpectedBlockTime(this.api) + const expectedBlockTime = new BN(getBlockTimeMs(this.api)) const weight = await useWeightImpl(this.api as ApiPromise, expectedBlockTime, new BN(1)) const gasLimit = weight.isWeightV2 ? weight.weightV2 : weight.isEmpty ? -1 : weight.weight this.logger.debug('Sending address: ', this.pair.address) diff --git a/packages/procaptcha/package.json b/packages/procaptcha/package.json index fb8552dffd..1373b92124 100644 --- a/packages/procaptcha/package.json +++ b/packages/procaptcha/package.json @@ -11,7 +11,6 @@ "npm": ">=9" }, "scripts": { - "test": "echo \"No test specified\"", "clean": "tsc --build --clean", "build": "tsc --build --verbose tsconfig.json", "build:cjs": "npx vite --config vite.cjs.config.ts build", @@ -20,7 +19,8 @@ "prettier": "npx prettier . --check --no-error-on-unmatched-pattern --ignore-path ../../.eslintignore", "prettier:fix": "npm run prettier -- --write", "lint": "npm run eslint && npm run prettier", - "lint:fix": "npm run eslint:fix && npm run prettier:fix" + "lint:fix": "npm run eslint:fix && npm run prettier:fix", + "test": "NODE_ENV=test vitest --run --config vite.test.config.ts" }, "exports": { ".": { diff --git a/packages/procaptcha/src/modules/Manager.ts b/packages/procaptcha/src/modules/Manager.ts index 10d6480677..40883850f0 100644 --- a/packages/procaptcha/src/modules/Manager.ts +++ b/packages/procaptcha/src/modules/Manager.ts @@ -41,14 +41,12 @@ import { import { ProsopoCaptchaContract, wrapQuery } from '@prosopo/contract' import { ProviderApi } from '@prosopo/api' import { RandomProvider } from '@prosopo/captcha-contract/types-returns' -import { SignerPayloadRaw } from '@polkadot/types/types' import { WsProvider } from '@polkadot/rpc-provider/ws' import { ContractAbi as abiJson } from '@prosopo/captcha-contract/contract-info' import { at, hashToHex } from '@prosopo/util' import { buildUpdateState, getDefaultEvents } from '@prosopo/procaptcha-common' import { randomAsHex } from '@polkadot/util-crypto/random' import { sleep } from '../utils/utils.js' -import { stringToU8a } from '@polkadot/util/string' import ProsopoCaptchaApi from './ProsopoCaptchaApi.js' import storage from './storage.js' @@ -187,10 +185,10 @@ export function Manager( } // Check if there is a provider in local storage or get a random one from the contract - const providerUrlFromStorage = storage.getProviderUrl() + const procaptchaStorage = storage.getProcaptchaStorage() let providerApi: ProviderApi - if (providerUrlFromStorage) { - providerApi = await loadProviderApi(providerUrlFromStorage) + if (procaptchaStorage.providerUrl && procaptchaStorage.blockNumber) { + providerApi = await loadProviderApi(procaptchaStorage.providerUrl) // if the provider was already in storage, the user may have already solved some captchas but they have not been put on chain yet // so contact the provider to check if this is the case @@ -198,13 +196,14 @@ export function Manager( const verifyDappUserResponse = await providerApi.verifyDappUser( getDappAccount(), account.account.address, + procaptchaStorage.blockNumber, undefined, configOptional.challengeValidLength ) if (verifyDappUserResponse.verified) { updateState({ isHuman: true, loading: false }) const output: ProcaptchaOutput = { - [ApiParams.providerUrl]: providerUrlFromStorage, + [ApiParams.providerUrl]: procaptchaStorage.providerUrl, [ApiParams.user]: account.account.address, [ApiParams.dapp]: getDappAccount(), [ApiParams.commitmentId]: hashToHex(verifyDappUserResponse.commitmentId), @@ -216,16 +215,10 @@ export function Manager( } } catch (err) { // if the provider is down, we should continue with the process of selecting a random provider - console.error('Error contacting provider from storage', providerUrlFromStorage) + console.error('Error contacting provider from storage', procaptchaStorage.providerUrl) // continue as if the provider was not in storage } } - const payload = { - address: account.account.address, - data: stringToU8a('message'), - type: 'bytes', - } - const signed = await account.extension!.signer!.signRaw!(payload as unknown as SignerPayloadRaw) // get a random provider const getRandomProviderResponse: RandomProvider = await wrapQuery( @@ -336,11 +329,11 @@ export function Manager( loading: false, }) if (state.isHuman) { - const trimmedUrl = trimProviderUrl(captchaApi.provider.provider.url.toString()) + const providerUrl = trimProviderUrl(captchaApi.provider.provider.url.toString()) // cache this provider for future use - storage.setProviderUrl(trimmedUrl) + storage.setProcaptchaStorage({ ...storage.getProcaptchaStorage(), providerUrl, blockNumber }) events.onHuman({ - providerUrl: trimmedUrl, + providerUrl, user: account.account.address, dapp: getDappAccount(), commitmentId: hashToHex(submission[1]), @@ -549,7 +542,8 @@ export function Manager( } const exportData = async (events: StoredEvents) => { - const providerUrlFromStorage = storage.getProviderUrl() + const procaptchaStorage = storage.getProcaptchaStorage() + const providerUrlFromStorage = procaptchaStorage.providerUrl let providerApi: ProviderApi if (providerUrlFromStorage) { @@ -564,7 +558,8 @@ export function Manager( providerApi = await loadProviderApi(providerUrl) } - const providerUrl = storage.getProviderUrl() || state.captchaApi?.provider.provider.url.toString() + const providerUrl = + storage.getProcaptchaStorage().providerUrl || state.captchaApi?.provider.provider.url.toString() if (!providerUrl) { return } diff --git a/packages/procaptcha/src/modules/ProsopoCaptchaApi.ts b/packages/procaptcha/src/modules/ProsopoCaptchaApi.ts index 305a27a72f..a981fb3d97 100644 --- a/packages/procaptcha/src/modules/ProsopoCaptchaApi.ts +++ b/packages/procaptcha/src/modules/ProsopoCaptchaApi.ts @@ -121,7 +121,6 @@ export class ProsopoCaptchaApi implements ProsopoCaptchaApiInterface { tree.build(captchasHashed) const commitmentId = tree.root!.hash - console.log('solveCaptchaChallenge commitmentId', commitmentId) const tx: ContractSubmittableResult | undefined = undefined let signature: string | undefined = undefined diff --git a/packages/procaptcha/src/modules/storage.ts b/packages/procaptcha/src/modules/storage.ts index d21fd0ac0e..15b35463aa 100644 --- a/packages/procaptcha/src/modules/storage.ts +++ b/packages/procaptcha/src/modules/storage.ts @@ -11,41 +11,46 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { ProcaptchaLocalStorage, ProsopoLocalStorageSchema } from '@prosopo/types' +import { hexToString } from '@polkadot/util' +import { stringToHex } from '@polkadot/util/string' -const CURRENT_ACCOUNT_KEY = '@prosopo/current_account' -const PROVIDER_URL_KEY = '@prosopo/provider' +const PROCAPTCHA_STORAGE_KEY = '@prosopo/procaptcha' /** - * Sets default `account` + * Gets procaptcha storage object from localStorage */ -function setAccount(account: string) { - localStorage.setItem(CURRENT_ACCOUNT_KEY, account) +function getProcaptchaStorage(): ProcaptchaLocalStorage { + return ProsopoLocalStorageSchema.parse( + JSON.parse(hexToString(localStorage.getItem(PROCAPTCHA_STORAGE_KEY) || '0x7b7d')) + ) } /** - * Gets default `account` + * Sets procaptcha storage hex string in localStorage + * @param storage */ -function getAccount(): string | null { - return localStorage.getItem(CURRENT_ACCOUNT_KEY) +function setProcaptchaStorage(storage: ProcaptchaLocalStorage) { + localStorage.setItem(PROCAPTCHA_STORAGE_KEY, stringToHex(JSON.stringify(ProsopoLocalStorageSchema.parse(storage)))) } /** - * Sets `providerUrl` for `account` + * Sets default `account` */ -function setProviderUrl(providerUrl: string) { - localStorage.setItem(PROVIDER_URL_KEY, providerUrl) +function setAccount(account: string) { + setProcaptchaStorage({ ...getProcaptchaStorage(), account }) } /** - * Gets `providerUrl` + * Gets default `account` */ -function getProviderUrl(): string | null { - return localStorage.getItem(PROVIDER_URL_KEY) +function getAccount(): string | null { + return getProcaptchaStorage().account || null } export default { setAccount, getAccount, - setProviderUrl, - getProviderUrl, + setProcaptchaStorage, + getProcaptchaStorage, } diff --git a/packages/procaptcha/src/tests/modules/storage.test.ts b/packages/procaptcha/src/tests/modules/storage.test.ts new file mode 100644 index 0000000000..342ac1267a --- /dev/null +++ b/packages/procaptcha/src/tests/modules/storage.test.ts @@ -0,0 +1,35 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { describe, expect, test } from 'vitest' +import storage from '../../modules/storage.js' + +const { setAccount, getAccount, getProcaptchaStorage, setProcaptchaStorage } = storage + +describe('storage tests', () => { + test('sets and gets account', async () => { + setAccount('abc') + expect(getAccount()).to.equal('abc') + }) + + test('sets provider URL and block number', async () => { + setAccount('abc') + const procaptchaStorage = getProcaptchaStorage() + setProcaptchaStorage({ ...procaptchaStorage, providerUrl: 'http://localhost:9229', blockNumber: 100 }) + expect(getProcaptchaStorage()).toMatchObject({ + account: 'abc', + providerUrl: 'http://localhost:9229', + blockNumber: 100, + }) + }) +}) diff --git a/packages/procaptcha/vite.test.config.ts b/packages/procaptcha/vite.test.config.ts new file mode 100644 index 0000000000..2305befe40 --- /dev/null +++ b/packages/procaptcha/vite.test.config.ts @@ -0,0 +1,36 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { defineConfig } from 'vitest/config' +import dotenv from 'dotenv' +import fs from 'fs' +import path from 'path' +process.env.NODE_ENV = 'test' +// if .env.test exists at this level, use it, otherwise use the one at the root +const envFile = `.env.${process.env.NODE_ENV || 'development'}` +let envPath = envFile +if (fs.existsSync(envFile)) { + envPath = path.resolve(envFile) +} else if (fs.existsSync(`../../${envFile}`)) { + envPath = path.resolve(`../../${envFile}`) +} else { + throw new Error(`No ${envFile} file found`) +} + +dotenv.config({ path: envPath }) + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}) diff --git a/packages/provider/src/api/authMiddleware.ts b/packages/provider/src/api/authMiddleware.ts index 46ba9cc1e0..df55146c2d 100644 --- a/packages/provider/src/api/authMiddleware.ts +++ b/packages/provider/src/api/authMiddleware.ts @@ -16,6 +16,7 @@ import { NextFunction, Request, Response } from 'express' import { ProsopoApiError, ProsopoEnvError } from '@prosopo/common' import { ProviderEnvironment } from '@prosopo/types-env' import { Tasks } from '../index.js' +import { getCurrentBlockNumber } from '@prosopo/contract' import { hexToU8a, isHex } from '@polkadot/util' export const authMiddleware = (tasks: Tasks, env: ProviderEnvironment) => { @@ -64,7 +65,7 @@ const verifyEnvironmentKeyPair = (env: ProviderEnvironment) => { const verifyBlockNumber = async (blockNumber: string, tasks: Tasks) => { const parsedBlockNumber = parseInt(blockNumber) - const currentBlockNumber = await tasks.getCurrentBlockNumber() + const currentBlockNumber = await getCurrentBlockNumber(tasks.contract.api) if ( isNaN(parsedBlockNumber) || diff --git a/packages/provider/src/api/captcha.ts b/packages/provider/src/api/captcha.ts index de148de7cb..a67f8f4d9d 100644 --- a/packages/provider/src/api/captcha.ts +++ b/packages/provider/src/api/captcha.ts @@ -33,6 +33,7 @@ import { CaptchaStatus } from '@prosopo/captcha-contract/types-returns' import { ProsopoApiError } from '@prosopo/common' import { ProviderEnvironment } from '@prosopo/types-env' import { Tasks } from '../tasks/tasks.js' +import { getBlockTimeMs, getCurrentBlockNumber } from '@prosopo/contract' import { parseBlockNumber } from '../util.js' import { parseCaptchaAssets } from '@prosopo/datasets' import { validateAddress } from '@polkadot/util-crypto/address' @@ -146,16 +147,19 @@ export function prosopoRouter(env: ProviderEnvironment): Router { : tasks.getDappUserCommitmentByAccount(parsed.user)) if (!solution) { - return res.json({ status: req.t('API.USER_NOT_VERIFIED'), verified: false }) + return res.json({ + [ApiParams.status]: req.t('API.USER_NOT_VERIFIED'), + [ApiParams.verified]: false, + }) } if (parsed.maxVerifiedTime) { - const currentBlockNumber = await tasks.getCurrentBlockNumber() - const blockTimeMs = await tasks.getBlockTimeMs() + const currentBlockNumber = await getCurrentBlockNumber(tasks.contract.api) + const blockTimeMs = getBlockTimeMs(tasks.contract.api) const timeSinceCompletion = (currentBlockNumber - solution.completedAt) * blockTimeMs const verificationResponse: VerificationResponse = { - status: req.t('API.USER_NOT_VERIFIED'), - verified: false, + [ApiParams.status]: req.t('API.USER_NOT_VERIFIED'), + [ApiParams.verified]: false, } if (timeSinceCompletion > parsed.maxVerifiedTime) { return res.json(verificationResponse) @@ -164,7 +168,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { const isApproved = solution.status === CaptchaStatus.approved const response: ImageVerificationResponse = { - status: req.t(isApproved ? 'API.USER_VERIFIED' : 'API.USER_NOT_VERIFIED'), + [ApiParams.status]: req.t(isApproved ? 'API.USER_VERIFIED' : 'API.USER_NOT_VERIFIED'), [ApiParams.verified]: isApproved, [ApiParams.commitmentId]: solution.id.toString(), [ApiParams.blockNumber]: solution.requestedAt, diff --git a/packages/provider/src/tasks/tasks.ts b/packages/provider/src/tasks/tasks.ts index 5e09afb82e..543ebf4782 100644 --- a/packages/provider/src/tasks/tasks.ts +++ b/packages/provider/src/tasks/tasks.ts @@ -43,7 +43,7 @@ import { CaptchaStatus, Dapp, Provider, RandomProvider } from '@prosopo/captcha- import { ContractPromise } from '@polkadot/api-contract/promise' import { Database, UserCommitmentRecord } from '@prosopo/types-database' import { Logger, ProsopoContractError, ProsopoEnvError, getLogger } from '@prosopo/common' -import { ProsopoCaptchaContract, getBlockNumber, wrapQuery } from '@prosopo/contract' +import { ProsopoCaptchaContract, getCurrentBlockNumber, wrapQuery } from '@prosopo/contract' import { ProviderEnvironment } from '@prosopo/types-env' import { SubmittableResult } from '@polkadot/api/submittable' import { at } from '@prosopo/util' @@ -353,7 +353,7 @@ export class Tasks { ) // Only do stuff if the request is in the local DB const userSignature = hexToU8a(signature) - const blockNumber = (await getBlockNumber(this.contract.api)).toNumber() + const blockNumber = await getCurrentBlockNumber(this.contract.api) if (pendingRequest) { const commit: UserCommitmentRecord = { id: commitmentId, @@ -535,10 +535,9 @@ export class Tasks { const currentTime = Date.now() const timeLimit = captchas.map((captcha) => captcha.captcha.timeLimitMs || 30000).reduce((a, b) => a + b, 0) - const deadlineTs = timeLimit + currentTime - const currentBlockNumber = await getBlockNumber(this.contract.api) - await this.db.storeDappUserPending(userAccount, requestHash, salt, deadlineTs, currentBlockNumber.toNumber()) + const currentBlockNumber = await getCurrentBlockNumber(this.contract.api) + await this.db.storeDappUserPending(userAccount, requestHash, salt, deadlineTs, currentBlockNumber) return { captchas, requestHash } } @@ -699,21 +698,6 @@ export class Tasks { return await this.db.getDataset(datasetId) } - /** - * Get the current block number - */ - async getCurrentBlockNumber(): Promise { - return (await getBlockNumber(this.contract.api)).toNumber() - } - - /** - * Get the current block time in milliseconds - */ - async getBlockTimeMs(): Promise { - const blockTime = this.contract.api.consts.babe.expectedBlockTime - return blockTime.toNumber() - } - async saveCaptchaEvent(events: StoredEvents, accountId: string) { if (!this.config.devOnlyWatchEvents || !this.config.mongoEventsUri) { this.logger.info('Dev watch events not set to true, not saving events') diff --git a/packages/provider/src/tests/tasks/tasks.test.ts b/packages/provider/src/tests/tasks/tasks.test.ts index af0089506f..ba9086487a 100644 --- a/packages/provider/src/tests/tasks/tasks.test.ts +++ b/packages/provider/src/tests/tasks/tasks.test.ts @@ -23,7 +23,7 @@ import { } from '@prosopo/datasets' import { CaptchaSolution, DappUserSolutionResult } from '@prosopo/types' import { CaptchaStatus, Commit, DappPayee, Payee } from '@prosopo/captcha-contract/types-returns' -import { ContractDeployer, getBlockNumber, getDispatchError, getPairAsync, wrapQuery } from '@prosopo/contract' +import { ContractDeployer, getCurrentBlockNumber, getDispatchError, getPairAsync, wrapQuery } from '@prosopo/contract' import { DappAbiJSON, DappWasm } from '../dataUtils/dapp-example-contract/loadFiles.js' import { EventRecord } from '@polkadot/types/interfaces' import { MockEnvironment, ProviderEnvironment } from '@prosopo/env' @@ -319,7 +319,7 @@ describe.sequential('CONTRACT TASKS', async function (): Promise { pendingRequestSalt ) - const blockNumber = (await getBlockNumber(env.getApi())).toNumber() + const blockNumber = await getCurrentBlockNumber(env.getApi()) if ('storeDappUserPending' in env.getDb()) { await env diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index c228c492b5..5f1a0e969d 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. import { ApiPromise } from '@polkadot/api/promise/Api' -import { BN } from '@polkadot/util' import { ContractAbi, NetworkConfig, @@ -23,13 +22,16 @@ import { import { Keyring } from '@polkadot/keyring' import { KeyringPair } from '@polkadot/keyring/types' import { LogLevel, Logger, ProsopoEnvError, getLogger, trimProviderUrl } from '@prosopo/common' -import { ProsopoCaptchaContract, getExpectedBlockTime, getZeroAddress } from '@prosopo/contract' +import { ProsopoCaptchaContract, getBlockTimeMs, getCurrentBlockNumber, getZeroAddress } from '@prosopo/contract' import { ProviderApi } from '@prosopo/api' import { RandomProvider } from '@prosopo/captcha-contract/types-returns' import { WsProvider } from '@polkadot/rpc-provider/ws' import { ContractAbi as abiJson } from '@prosopo/captcha-contract/contract-info' import { get } from '@prosopo/util' +export const DEFAULT_MAX_VERIFIED_TIME_CACHED = 60 * 1000 +export const DEFAULT_MAX_VERIFIED_TIME_CONTRACT = 5 * 60 * 1000 + export class ProsopoServer { config: ProsopoServerConfigOutput contract: ProsopoCaptchaContract | undefined @@ -113,11 +115,6 @@ export class ProsopoServer { return this.contract } - async getViableHistoricBlockCount(maxVerifiedTime?: number): Promise { - const expectedBlockTime = getExpectedBlockTime(this.getApi()) - return new BN(maxVerifiedTime || 60000).div(expectedBlockTime).toNumber() - } - /** * Check if the provider was actually chosen at blockNumber. * - If no blockNumber is provided, check the last `n` blocks where `n` is the number of blocks that fit in @@ -127,43 +124,93 @@ export class ProsopoServer { * @param dapp * @param providerUrl * @param blockNumber - * @param maxVerifiedTime * @returns */ - async checkRandomProvider( - user: string, - dapp: string, - providerUrl?: string, - blockNumber?: number, - maxVerifiedTime?: number - ) { + async checkRandomProvider(user: string, dapp: string, providerUrl: string, blockNumber: number) { + const block = await this.getApi().rpc.chain.getBlockHash(blockNumber) // Check if the provider was actually chosen at blockNumber - let blocksToCheck: number[] = [] - if (blockNumber) { - blocksToCheck = [blockNumber] - } else { - const numberOfHistoricBlocksToCheck = await this.getViableHistoricBlockCount(maxVerifiedTime) - const currentBlockNumber = (await this.getApi().rpc.chain.getBlock()).block.header.number.toNumber() - blocksToCheck = Array.from( - { length: numberOfHistoricBlocksToCheck }, - (_, index) => currentBlockNumber - index - ) + const getRandomProviderResponse = await this.getContract().queryAtBlock( + block, + 'getRandomActiveProvider', + [user, dapp] + ) + if (trimProviderUrl(getRandomProviderResponse.provider.url.toString()) === providerUrl) { + return getRandomProviderResponse.provider } - while (blocksToCheck.length > 0) { - const block = await this.getApi().rpc.chain.getBlockHash(blocksToCheck.pop() as number) - const getRandomProviderResponse = await this.getContract().queryAtBlock( - block, - 'getRandomActiveProvider', - [user, dapp] - ) - if (trimProviderUrl(getRandomProviderResponse.provider.url.toString()) === providerUrl) { - return getRandomProviderResponse.provider - } - } return undefined } + /** + * Verify the time since the blockNumber is equal to or less than the maxVerifiedTime. + * @param maxVerifiedTime + * @param blockNumber + */ + public async verifyRecency(blockNumber: number, maxVerifiedTime: number) { + const contractApi = await this.getContractApi() + // Get the current block number + const currentBlock = await getCurrentBlockNumber(contractApi.api) + // Calculate how many blocks have passed since the blockNumber + const blocksPassed = currentBlock - blockNumber + // Get the expected block time + const blockTime = getBlockTimeMs(contractApi.api) + // Check if the time since the last correct captcha is within the limit + return blockTime * blocksPassed <= maxVerifiedTime + } + + /** + * Verify the user with the contract. We check the contract to see if the user has completed a captcha in the + * past. If they have, we check the time since the last correct captcha is within the maxVerifiedTime and we check + * whether the user is marked as human within the contract. + * @param user + * @param maxVerifiedTime + */ + public async verifyContract(user: string, maxVerifiedTime = DEFAULT_MAX_VERIFIED_TIME_CONTRACT) { + const contractApi = await this.getContractApi() + this.logger.info('Provider URL not provided. Verifying with contract.') + const correctCaptchaBlockNumber = (await contractApi.query.dappOperatorLastCorrectCaptcha(user)).value + .unwrap() + .unwrap() + .before.valueOf() + const verifyRecency = await this.verifyRecency(correctCaptchaBlockNumber, maxVerifiedTime) + const isHuman = (await contractApi.query.dappOperatorIsHumanUser(user, this.config.solutionThreshold)).value + .unwrap() + .unwrap() + return isHuman && verifyRecency + } + + /** + * Verify the user with the provider URL passed in. If a challenge is provided, we use the challenge to verify the + * user. If not, we use the user, dapp, and optionally the commitmentID, to verify the user. + * @param providerUrl + * @param dapp + * @param user + * @param blockNumber + * @param challenge + * @param commitmentId + * @param maxVerifiedTime + */ + public async verifyProvider( + providerUrl: string, + dapp: string, + user: string, + blockNumber: number, + challenge?: string, + commitmentId?: string, + maxVerifiedTime = DEFAULT_MAX_VERIFIED_TIME_CACHED + ) { + this.logger.info('Verifying with provider.') + const providerApi = await this.getProviderApi(providerUrl) + if (challenge) { + const result = await providerApi.submitPowCaptchaVerify(challenge, dapp) + // We don't care about recency with PoW challenges as they are single use, so just return the verified result + return result.verified + } + const result = await providerApi.verifyDappUser(dapp, user, blockNumber, commitmentId, maxVerifiedTime) + const verifyRecency = await this.verifyRecency(result.blockNumber, maxVerifiedTime) + return result.verified && verifyRecency + } + /** * * @param payload Info output by procaptcha on completion of the captcha process @@ -172,41 +219,31 @@ export class ProsopoServer { */ public async isVerified(payload: ProcaptchaOutput, maxVerifiedTime?: number): Promise { const { user, dapp, providerUrl, commitmentId, blockNumber, challenge } = payload - const contractApi = await this.getContractApi() - - const randomProvider = await this.checkRandomProvider(user, dapp, providerUrl, blockNumber, maxVerifiedTime) - - if (!randomProvider) { - this.logger.info('Random provider selection failed') - // We have not been able to repeat the provider selection - return false - } - if (providerUrl) { - this.logger.info('Random provider is valid. Verifying with provider.') - // We can now trust the provider URL as it has been shown to have been randomly selected - const providerApi = await this.getProviderApi(providerUrl) - if (challenge) { - const result = await providerApi.submitPowCaptchaVerify(challenge, dapp) - return result.verified - } - const result = await providerApi.verifyDappUser(dapp, user, commitmentId, maxVerifiedTime) - return result.verified - } else { - this.logger.info('Provider URL not provided. Verifying with contract.') - // Check the time since the last correct captcha is less than the maxVerifiedTime - const blockTime = contractApi.api.consts.babe.expectedBlockTime.toNumber() - const blocksSinceLastCorrectCaptcha = (await contractApi.query.dappOperatorLastCorrectCaptcha(user)).value - .unwrap() - .unwrap() - .before.valueOf() - if (maxVerifiedTime && blockTime * blocksSinceLastCorrectCaptcha > maxVerifiedTime) { + if (providerUrl && blockNumber) { + // By requiring block number, we load balance requests to the providers by requiring that the random + // provider selection should be repeatable. If we have a block number, we check the provider was selected + // at that block. + const randomProvider = await this.checkRandomProvider(user, dapp, providerUrl, blockNumber) + if (!randomProvider) { + this.logger.info('Random provider selection failed') + // We have not been able to repeat the provider selection return false } - return (await contractApi.query.dappOperatorIsHumanUser(user, this.config.solutionThreshold)).value - .unwrap() - .unwrap() + // If we have a providerURL and a blockNumber, we verify with the provider + return await this.verifyProvider( + providerUrl, + dapp, + user, + blockNumber, + challenge, + commitmentId, + maxVerifiedTime + ) + } else { + // If we don't have a providerURL, we verify with the contract + return await this.verifyContract(user, maxVerifiedTime) } } diff --git a/packages/types/src/api/api.ts b/packages/types/src/api/api.ts index 4d4f8be06a..037e70f02e 100644 --- a/packages/types/src/api/api.ts +++ b/packages/types/src/api/api.ts @@ -37,6 +37,7 @@ export interface ProviderApiInterface { verifyDappUser( dapp: AccountId, userAccount: AccountId, + blockNumber: number, commitmentId?: string, maxVerifiedTime?: number ): Promise diff --git a/packages/types/src/procaptcha/api.ts b/packages/types/src/procaptcha/api.ts index ef2abbaa3b..7e5deac24c 100644 --- a/packages/types/src/procaptcha/api.ts +++ b/packages/types/src/procaptcha/api.ts @@ -13,7 +13,7 @@ // limitations under the License. import { CaptchaResponseBody } from '../provider/index.js' import { CaptchaSolution } from '../datasets/index.js' -import { IProsopoCaptchaContract } from '../contract/interface.js' +import { IProsopoCaptchaContract } from '../contract/index.js' import { ProviderApiInterface } from '../api/index.js' import { RandomProvider } from '@prosopo/captcha-contract/types-returns' import { Signer } from '@polkadot/api/types' diff --git a/packages/types/src/procaptcha/index.ts b/packages/types/src/procaptcha/index.ts index b03cf16e56..124ddcbb56 100644 --- a/packages/types/src/procaptcha/index.ts +++ b/packages/types/src/procaptcha/index.ts @@ -16,4 +16,5 @@ export * from './client.js' export * from './collector.js' export * from './manager.js' export * from './props.js' +export * from './storage.js' export * from './utils.js' diff --git a/packages/types/src/procaptcha/storage.ts b/packages/types/src/procaptcha/storage.ts new file mode 100644 index 0000000000..c230e5edfb --- /dev/null +++ b/packages/types/src/procaptcha/storage.ts @@ -0,0 +1,22 @@ +// Copyright 2021-2024 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { z } from 'zod' + +export const ProsopoLocalStorageSchema = z.object({ + account: z.string().optional(), + blockNumber: z.number().optional(), + providerUrl: z.string().optional(), +}) + +export type ProcaptchaLocalStorage = z.infer diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 5745998080..6e91f433d7 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -97,6 +97,7 @@ export type CaptchaSolutionBodyType = zInfer export const VerifySolutionBody = object({ [ApiParams.dapp]: string(), [ApiParams.user]: string(), + [ApiParams.blockNumber]: number(), [ApiParams.commitmentId]: string().optional(), [ApiParams.maxVerifiedTime]: number().optional(), })