diff --git a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/schemas.ts b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/schemas.ts index 24d30cceb9..1a0df63925 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/schemas.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/hcaptcha-labeling/schemas.ts @@ -11,19 +11,12 @@ export const dailyHmtSpentResponseSchema = z.object({ export const hcaptchaUserStatsResponseSchema = z.object({ balance: z.object({ - available: z.number(), - estimated: z.number(), recent: z.number(), total: z.number(), }), served: z.number(), solved: z.number(), - verified: z.number(), currentDateStats: z.object({ - billing_units: z.number(), - bypass: z.number(), - served: z.number(), solved: z.number(), }), - currentEarningsStats: z.number(), }); diff --git a/packages/apps/job-launcher/server/src/common/constants/tokens.ts b/packages/apps/job-launcher/server/src/common/constants/tokens.ts index ae784e8497..50f93ecad4 100644 --- a/packages/apps/job-launcher/server/src/common/constants/tokens.ts +++ b/packages/apps/job-launcher/server/src/common/constants/tokens.ts @@ -15,46 +15,64 @@ export const TOKEN_ADDRESSES: { address: NETWORKS[ChainId.MAINNET]!.hmtAddress, decimals: 18, }, - // [EscrowFundToken.USDT]: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 }, - // [EscrowFundToken.USDC]: { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606EB48', decimals: 6 }, + [EscrowFundToken.USDT]: { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, + [EscrowFundToken.USDC]: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }, }, [ChainId.SEPOLIA]: { [EscrowFundToken.HMT]: { address: NETWORKS[ChainId.SEPOLIA]!.hmtAddress, decimals: 18, }, - // [EscrowFundToken.USDT]: { address: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', decimals: 6 }, - // [EscrowFundToken.USDC]: { address: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', decimals: 6 }, + [EscrowFundToken.USDT]: { + address: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + decimals: 6, + }, + [EscrowFundToken.USDC]: { + address: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + decimals: 6, + }, }, [ChainId.BSC_MAINNET]: { [EscrowFundToken.HMT]: { address: NETWORKS[ChainId.BSC_MAINNET]!.hmtAddress, decimals: 18, }, - // [EscrowFundToken.USDT]: { address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 }, - // [EscrowFundToken.USDC]: { address: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', decimals: 18 }, }, [ChainId.POLYGON]: { [EscrowFundToken.HMT]: { address: NETWORKS[ChainId.POLYGON]!.hmtAddress, decimals: 18, }, - // [EscrowFundToken.USDT]: { address: '0x3813e82e6f7098b9583FC0F33a962D02018B6803', decimals: 6 }, - // [EscrowFundToken.USDC]: { address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', decimals: 6 }, + [EscrowFundToken.USDT]: { + address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + decimals: 6, + }, + [EscrowFundToken.USDC]: { + address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', + decimals: 6, + }, }, [ChainId.POLYGON_AMOY]: { [EscrowFundToken.HMT]: { address: NETWORKS[ChainId.POLYGON_AMOY]!.hmtAddress, decimals: 18, }, - // [EscrowFundToken.USDC]: { address: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', decimals: 6 }, + [EscrowFundToken.USDC]: { + address: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', + decimals: 6, + }, }, [ChainId.AURORA_TESTNET]: { [EscrowFundToken.HMT]: { address: NETWORKS[ChainId.AURORA_TESTNET]!.hmtAddress, decimals: 18, }, - // [EscrowFundToken.USDC]: { address: '0x41e94eb019c0762f9bfcf9fb1e58725bfb0e7582', decimals: 6 }, }, [ChainId.LOCALHOST]: { [EscrowFundToken.HMT]: { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index f8bd94c771..932fd509f7 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -241,7 +241,7 @@ export class PaymentService { } const signer = this.web3Service.getSigner(dto.chainId); - const tokenAddress = transaction.logs[0].address; + const tokenAddress = transaction.logs[0].address.toLowerCase(); const tokenContract: HMToken = HMToken__factory.connect( tokenAddress, @@ -262,7 +262,10 @@ export class PaymentService { const tokenId = (await tokenContract.symbol()).toLowerCase(); const token = TOKEN_ADDRESSES[dto.chainId]?.[tokenId as EscrowFundToken]; - if (token?.address !== tokenAddress || !CoingeckoTokenId[tokenId]) { + if ( + token?.address?.toLowerCase() !== tokenAddress || + !CoingeckoTokenId[tokenId] + ) { throw new ConflictError(ErrorPayment.UnsupportedToken); } diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.spec.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.spec.ts index fe35e5eadf..00cf233469 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.spec.ts @@ -48,10 +48,14 @@ describe('CvatPayoutsCalculator', () => { const mockedGetIntermediateResultsUrl = jest .fn() .mockImplementation(async () => faker.internet.url()); + const mockedGetTokenAddress = jest.fn().mockImplementation(async () => { + return faker.finance.ethereumAddress(); + }); beforeAll(() => { mockedEscrowClient.build.mockResolvedValue({ getIntermediateResultsUrl: mockedGetIntermediateResultsUrl, + getTokenAddress: mockedGetTokenAddress, } as unknown as EscrowClient); }); @@ -80,6 +84,83 @@ describe('CvatPayoutsCalculator', () => { ); }); + it('should properly calculate workers bounties trimming the decimals', async () => { + const annotators = [ + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + ]; + + const jobsPerAnnotator = faker.number.int({ min: 1, max: 3 }); + const tokenDecimals = BigInt(6); + + const annotationsMeta: CvatAnnotationMeta = { + jobs: Array.from( + { length: jobsPerAnnotator * annotators.length }, + (_v, index: number) => ({ + job_id: index, + final_result_id: faker.number.int(), + }), + ), + results: [], + }; + for (const job of annotationsMeta.jobs) { + const annotatorIndex = job.job_id % annotators.length; + + annotationsMeta.results.push({ + id: job.final_result_id, + job_id: job.job_id, + annotator_wallet_address: annotators[annotatorIndex], + annotation_quality: faker.number.float(), + }); + } + + // imitate weird case: job w/o result + annotationsMeta.jobs.push({ + job_id: faker.number.int(), + final_result_id: faker.number.int(), + }); + // imitate weird case: result w/o job + annotationsMeta.results.push({ + id: faker.number.int(), + job_id: faker.number.int(), + annotator_wallet_address: faker.helpers.arrayElement(annotators), + annotation_quality: faker.number.float(), + }); + + mockedStorageService.downloadJsonLikeData.mockResolvedValueOnce( + annotationsMeta, + ); + mockedWeb3Service.getTokenDecimals.mockResolvedValueOnce(tokenDecimals); + + const mockedJobBounty = '0.123456789'; // more decimals than token has + const manifest = { + ...generateCvatManifest(), + job_bounty: mockedJobBounty, + }; + + const payouts = await calculator.calculate({ + chainId, + escrowAddress, + manifest, + finalResultsUrl: faker.internet.url(), + }); + + const trimmedBounty = '0.123456'; + + const expectedAmountPerAnnotator = + BigInt(jobsPerAnnotator) * + ethers.parseUnits(trimmedBounty, tokenDecimals); + + const expectedPayouts = annotators.map((address) => ({ + address, + amount: expectedAmountPerAnnotator, + })); + + expect(_.sortBy(payouts, 'address')).toEqual( + _.sortBy(expectedPayouts, 'address'), + ); + }); + it('should properly calculate workers bounties', async () => { const annotators = [ faker.finance.ethereumAddress(), @@ -87,6 +168,7 @@ describe('CvatPayoutsCalculator', () => { ]; const jobsPerAnnotator = faker.number.int({ min: 1, max: 3 }); + const tokenDecimals = BigInt(faker.number.int({ min: 6, max: 18 })); const annotationsMeta: CvatAnnotationMeta = { jobs: Array.from( @@ -125,6 +207,7 @@ describe('CvatPayoutsCalculator', () => { mockedStorageService.downloadJsonLikeData.mockResolvedValueOnce( annotationsMeta, ); + mockedWeb3Service.getTokenDecimals.mockResolvedValueOnce(tokenDecimals); const manifest = generateCvatManifest(); @@ -136,7 +219,8 @@ describe('CvatPayoutsCalculator', () => { }); const expectedAmountPerAnnotator = - BigInt(jobsPerAnnotator) * ethers.parseUnits(manifest.job_bounty, 18); + BigInt(jobsPerAnnotator) * + ethers.parseUnits(manifest.job_bounty, tokenDecimals); const expectedPayouts = annotators.map((address) => ({ address, diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.ts index 5a3a34d617..75d14dcc23 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/cvat-payouts-calculator.ts @@ -46,7 +46,15 @@ export class CvatPayoutsCalculator implements EscrowPayoutsCalculator { throw new Error('Invalid annotation meta'); } - const jobBountyValue = ethers.parseUnits(manifest.job_bounty, 18); + const tokenAddress = await escrowClient.getTokenAddress(escrowAddress); + const tokenDecimals = await this.web3Service.getTokenDecimals( + chainId, + tokenAddress, + ); + const jobBountyValue = this.parseJobBounty( + manifest.job_bounty, + Number(tokenDecimals), + ); const workersBounties = new Map(); for (const job of annotations.jobs) { @@ -76,4 +84,21 @@ export class CvatPayoutsCalculator implements EscrowPayoutsCalculator { }), ); } + + private parseJobBounty(jobBounty: string, tokenDecimals: number): bigint { + const parts = jobBounty.split('.'); + if (parts.length > 1) { + const decimalsInBounty = parts[1].length; + if (decimalsInBounty > tokenDecimals) { + if (tokenDecimals === 0) { + return ethers.parseUnits(parts[0], tokenDecimals); + } + return ethers.parseUnits( + `${parts[0]}.${parts[1].slice(0, tokenDecimals)}`, + tokenDecimals, + ); + } + } + return ethers.parseUnits(jobBounty, tokenDecimals); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.spec.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.spec.ts index 5fa8e7fe4f..955ecbea3f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.spec.ts @@ -1,15 +1,21 @@ +jest.mock('@human-protocol/sdk'); + import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; +import { EscrowClient } from '@human-protocol/sdk'; import { Test } from '@nestjs/testing'; import { ethers } from 'ethers'; import _ from 'lodash'; import { StorageService } from '@/modules/storage'; +import { Web3Service } from '@/modules/web3'; import { generateFortuneManifest, generateFortuneSolution } from '../fixtures'; import { FortunePayoutsCalculator } from './fortune-payouts-calculator'; const mockedStorageService = createMock(); +const mockedWeb3Service = createMock(); +const mockedEscrowClient = jest.mocked(EscrowClient); describe('FortunePayoutsCalculator', () => { let calculator: FortunePayoutsCalculator; @@ -22,12 +28,23 @@ describe('FortunePayoutsCalculator', () => { provide: StorageService, useValue: mockedStorageService, }, + { + provide: Web3Service, + useValue: mockedWeb3Service, + }, ], }).compile(); calculator = moduleRef.get( FortunePayoutsCalculator, ); + + const mockedGetTokenAddress = jest.fn().mockImplementation(async () => { + return faker.finance.ethereumAddress(); + }); + mockedEscrowClient.build.mockResolvedValue({ + getTokenAddress: mockedGetTokenAddress, + } as unknown as EscrowClient); }); describe('calculate', () => { @@ -46,6 +63,9 @@ describe('FortunePayoutsCalculator', () => { const resultsUrl = faker.internet.url(); const manifest = generateFortuneManifest(); + const tokenDecimals = BigInt(faker.number.int({ min: 6, max: 18 })); + mockedWeb3Service.getTokenDecimals.mockResolvedValueOnce(tokenDecimals); + const payouts = await calculator.calculate({ chainId: faker.number.int(), escrowAddress: faker.finance.ethereumAddress(), @@ -56,8 +76,9 @@ describe('FortunePayoutsCalculator', () => { const expectedPayouts = validSolutions.map((s) => ({ address: s.workerAddress, amount: - BigInt(ethers.parseUnits(manifest.fundAmount.toString(), 'ether')) / - BigInt(validSolutions.length), + BigInt( + ethers.parseUnits(manifest.fundAmount.toString(), tokenDecimals), + ) / BigInt(validSolutions.length), })); expect(_.sortBy(payouts, 'address')).toEqual( diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.ts index fe89331717..7aa56e50f1 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/payouts-calculation/fortune-payouts-calculator.ts @@ -1,9 +1,11 @@ +import { EscrowClient } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { ethers } from 'ethers'; import type { OverrideProperties } from 'type-fest'; import { FortuneFinalResult, FortuneManifest } from '@/common/types'; import { StorageService } from '@/modules/storage'; +import { Web3Service } from '@/modules/web3'; import { CalclulatePayoutsInput, @@ -18,10 +20,15 @@ type CalculateFortunePayoutsInput = OverrideProperties< @Injectable() export class FortunePayoutsCalculator implements EscrowPayoutsCalculator { - constructor(private readonly storageService: StorageService) {} + constructor( + private readonly storageService: StorageService, + private readonly web3Service: Web3Service, + ) {} async calculate({ manifest, + chainId, + escrowAddress, finalResultsUrl, }: CalculateFortunePayoutsInput): Promise { const finalResults = @@ -33,8 +40,15 @@ export class FortunePayoutsCalculator implements EscrowPayoutsCalculator { .filter((result) => !result.error) .map((item) => item.workerAddress); + const signer = this.web3Service.getSigner(chainId); + const escrowClient = await EscrowClient.build(signer); + const tokenAddress = await escrowClient.getTokenAddress(escrowAddress); + const tokenDecimals = await this.web3Service.getTokenDecimals( + chainId, + tokenAddress, + ); const payoutAmount = - ethers.parseUnits(manifest.fundAmount.toString(), 18) / + ethers.parseUnits(manifest.fundAmount.toString(), tokenDecimals) / BigInt(recipients.length); return recipients.map((recipient) => ({ diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index e9a31b0349..e1bc6b34ae 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -1,3 +1,4 @@ +import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { ChainId } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { Wallet, ethers } from 'ethers'; @@ -91,4 +92,18 @@ export class Web3Service { throw new Error(`No gas price data for chain id: ${chainId}`); } + + async getTokenDecimals( + chainId: number, + tokenAddress: string, + ): Promise { + const signer = this.getSigner(chainId); + const tokenContract = HMToken__factory.connect(tokenAddress, signer); + try { + const decimals = await tokenContract.decimals(); + return decimals; + } catch (noop) { + throw new Error('Failed to fetch token decimals'); + } + } } diff --git a/packages/core/contracts/governance/MetaHumanGovernor.sol b/packages/core/contracts/governance/MetaHumanGovernor.sol index 57d4d9fb0c..43fd1ee495 100644 --- a/packages/core/contracts/governance/MetaHumanGovernor.sol +++ b/packages/core/contracts/governance/MetaHumanGovernor.sol @@ -422,8 +422,7 @@ contract MetaHumanGovernor is } /** - * @dev Retrieves the state of a proposal, ensuring that once the main voting period ends, - * the proposal cannot be canceled regardless of the collection status from spoke chains. + * @dev Retrieves the state of a proposal. * * @param proposalId The ID of the proposal. * @return The current state of the proposal. @@ -436,25 +435,7 @@ contract MetaHumanGovernor is override(Governor, GovernorTimelockControl) returns (ProposalState) { - ProposalState calculatedState = super.state(proposalId); - - // Check if the main voting period has ended - if ( - calculatedState == ProposalState.Succeeded || - calculatedState == ProposalState.Defeated - ) { - return calculatedState; - } - - // Check if the collection phase has finished - if ( - block.timestamp > proposalDeadline(proposalId) && - !collectionFinished[proposalId] - ) { - return ProposalState.Pending; - } - - return calculatedState; + return super.state(proposalId); } /** diff --git a/packages/core/scripts/create-proposal.ts b/packages/core/scripts/create-proposal.ts index 3c26dd815b..64aa57721b 100644 --- a/packages/core/scripts/create-proposal.ts +++ b/packages/core/scripts/create-proposal.ts @@ -26,7 +26,7 @@ async function main() { proposal.values, proposal.calldatas, proposal.description, - { value: ethers.parseEther('0.015') } + { value: ethers.parseEther('0.025') } ); await transactionResponse.wait(); diff --git a/packages/core/scripts/update-spokes.ts b/packages/core/scripts/update-spokes.ts index bd206a4ed7..fa23fb16be 100644 --- a/packages/core/scripts/update-spokes.ts +++ b/packages/core/scripts/update-spokes.ts @@ -32,6 +32,7 @@ async function main() { // can only be called by the governor const transaction = await governanceContract.updateSpokeContracts(spokeContracts); + await transaction.wait(); console.log( 'Spoke contracts updated successfully. TxHash:', transaction.hash diff --git a/packages/examples/cvat/exchange-oracle/alembic/versions/1757437703_add_cvat_webhooks_queue_216af22b5590.py b/packages/examples/cvat/exchange-oracle/alembic/versions/1757437703_add_cvat_webhooks_queue_216af22b5590.py new file mode 100644 index 0000000000..610be3fbc2 --- /dev/null +++ b/packages/examples/cvat/exchange-oracle/alembic/versions/1757437703_add_cvat_webhooks_queue_216af22b5590.py @@ -0,0 +1,70 @@ +""" +Add CVAT webhook queue + +Revision ID: 216af22b5590 +Revises: c32b36a87539 +Create Date: 2025-09-09 20:08:23.474046 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "216af22b5590" +down_revision = "c32b36a87539" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic ### + op.create_table( + "cvat_webhooks", + sa.Column( + "id", + sa.UUID(as_uuid=False), + server_default=sa.text("uuid_generate_v4()"), + nullable=False, + ), + sa.Column("cvat_project_id", sa.Integer(), nullable=False), + sa.Column("cvat_task_id", sa.Integer(), nullable=False), + sa.Column("cvat_job_id", sa.Integer(), nullable=False), + sa.Column("status", sa.String(), server_default="pending", nullable=True), + sa.Column("attempts", sa.Integer(), server_default="0", nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "wait_until", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True + ), + sa.Column("event_type", sa.String(), nullable=False), + sa.Column("event_data", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(["cvat_job_id"], ["jobs.cvat_id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["cvat_project_id"], ["projects.cvat_id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["cvat_task_id"], ["tasks.cvat_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_cvat_webhooks_cvat_job_id"), "cvat_webhooks", ["cvat_job_id"], unique=False + ) + op.create_index( + op.f("ix_cvat_webhooks_cvat_project_id"), "cvat_webhooks", ["cvat_project_id"], unique=False + ) + op.create_index( + op.f("ix_cvat_webhooks_cvat_task_id"), "cvat_webhooks", ["cvat_task_id"], unique=False + ) + op.create_index(op.f("ix_cvat_webhooks_id"), "cvat_webhooks", ["id"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic ### + op.drop_index(op.f("ix_cvat_webhooks_id"), table_name="cvat_webhooks") + op.drop_index(op.f("ix_cvat_webhooks_cvat_task_id"), table_name="cvat_webhooks") + op.drop_index(op.f("ix_cvat_webhooks_cvat_project_id"), table_name="cvat_webhooks") + op.drop_index(op.f("ix_cvat_webhooks_cvat_job_id"), table_name="cvat_webhooks") + op.drop_table("cvat_webhooks") + # ### end Alembic commands ### diff --git a/packages/examples/cvat/exchange-oracle/debug.py b/packages/examples/cvat/exchange-oracle/debug.py index 566dc74416..f686fc3eda 100644 --- a/packages/examples/cvat/exchange-oracle/debug.py +++ b/packages/examples/cvat/exchange-oracle/debug.py @@ -98,7 +98,11 @@ def patched_get_escrow(chain_id: int, escrow_address: str) -> EscrowData: logger.info(f"DEV: Using local manifest '{manifest_file}' for escrow '{escrow_address}'") return escrow - with mock.patch.object(EscrowUtils, "get_escrow", patched_get_escrow): + with ( + mock.patch.object(EscrowUtils, "get_escrow", patched_get_escrow), + mock.patch("src.chain.escrow.get_token_symbol", return_value="HMT"), + mock.patch("src.chain.web3.get_token_symbol", return_value="HMT"), + ): yield diff --git a/packages/examples/cvat/exchange-oracle/poetry.lock b/packages/examples/cvat/exchange-oracle/poetry.lock index c4c48fe2da..5f65733022 100644 --- a/packages/examples/cvat/exchange-oracle/poetry.lock +++ b/packages/examples/cvat/exchange-oracle/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "aiocache" @@ -2128,13 +2128,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "human-protocol-sdk" -version = "4.1.4" +version = "4.3.0" description = "A python library to launch escrow contracts to the HUMAN network." optional = false python-versions = "*" files = [ - {file = "human_protocol_sdk-4.1.4-py3-none-any.whl", hash = "sha256:c0dbaaf332a8e130d7378f36876a719bef595febffcc012a76af63ce9b2ed1a1"}, - {file = "human_protocol_sdk-4.1.4.tar.gz", hash = "sha256:9fb7b9886a7585e0ca5a8a4c390cc0f657b0e1ccdafa5504472deb1431e4438c"}, + {file = "human_protocol_sdk-4.3.0-py3-none-any.whl", hash = "sha256:498276ba47157615df7e914374fcb51f788e1892e2c169432deb2055108da525"}, + {file = "human_protocol_sdk-4.3.0.tar.gz", hash = "sha256:a1d172899c79c67d9b4266854252e02ae17f157bf1744710843e376c98c95738"}, ] [package.dependencies] @@ -3368,6 +3368,8 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, + {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, + {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -3870,6 +3872,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -4127,30 +4130,50 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, @@ -5047,4 +5070,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10,<3.13" -content-hash = "d967b19857b177bfc7a31a6a92eba46c8d26bf9fa5d6958fd71985395876c440" +content-hash = "d4b0b2cd3671d6e118b5fe545fe3ec2e69e9345bf321443ebf7f34368c085ab4" diff --git a/packages/examples/cvat/exchange-oracle/pyproject.toml b/packages/examples/cvat/exchange-oracle/pyproject.toml index a15e731ef6..7ca5e23a21 100644 --- a/packages/examples/cvat/exchange-oracle/pyproject.toml +++ b/packages/examples/cvat/exchange-oracle/pyproject.toml @@ -35,7 +35,7 @@ starlette = ">=0.40.0" # avoid the vulnerability with multipart/form-data cryptography = "<44.0.0" # human-protocol-sdk -> pgpy dep requires cryptography < 45 aiocache = {extras = ["msgpack", "redis"], version = "^0.12.3"} # convenient api for redis (async) cachelib = "^0.13.0" # convenient api for redis (sync) -human-protocol-sdk = "^4.1.4" +human-protocol-sdk = "^4.3.0" [tool.poetry.group.dev.dependencies] diff --git a/packages/examples/cvat/exchange-oracle/src/.env.template b/packages/examples/cvat/exchange-oracle/src/.env.template index 3d2ce69e73..03f0c5cb93 100644 --- a/packages/examples/cvat/exchange-oracle/src/.env.template +++ b/packages/examples/cvat/exchange-oracle/src/.env.template @@ -49,6 +49,9 @@ PROCESS_RECORDING_ORACLE_WEBHOOKS_INT= PROCESS_RECORDING_ORACLE_WEBHOOKS_CHUNK_SIZE= PROCESS_REPUTATION_ORACLE_WEBHOOKS_CHUNK_SIZE= PROCESS_REPUTATION_ORACLE_WEBHOOKS_INT= +PROCESS_CVAT_WEBHOOKS_WORKERS= +PROCESS_CVAT_WEBHOOKS_INT= +PROCESS_CVAT_WEBHOOKS_CHUNK_SIZE= TRACK_COMPLETED_PROJECTS_INT= TRACK_COMPLETED_TASKS_INT= TRACK_COMPLETED_ESCROWS_INT= @@ -102,6 +105,7 @@ ENABLE_CUSTOM_CLOUD_HOST= REQUEST_LOGGING_ENABLED= PROFILING_ENABLED= MANIFEST_CACHE_TTL= +TOKEN_SYMBOL_CACHE_TTL= MAX_DATA_STORAGE_CONNECTIONS= # Core diff --git a/packages/examples/cvat/exchange-oracle/src/chain/escrow.py b/packages/examples/cvat/exchange-oracle/src/chain/escrow.py index c5679fc7f7..cae6250c8c 100644 --- a/packages/examples/cvat/exchange-oracle/src/chain/escrow.py +++ b/packages/examples/cvat/exchange-oracle/src/chain/escrow.py @@ -6,6 +6,7 @@ from human_protocol_sdk.escrow import EscrowData, EscrowUtils from human_protocol_sdk.storage import StorageUtils +from src.chain.web3 import get_token_symbol from src.core.config import Config from src.core.types import OracleWebhookTypes from src.services.cache import Cache @@ -47,7 +48,7 @@ def validate_escrow( def download_manifest(chain_id: int, escrow_address: str) -> dict: escrow = get_escrow(chain_id, escrow_address) - manifest_content = StorageUtils.download_file_from_url(escrow.manifest_url).decode("utf-8") + manifest_content = StorageUtils.download_file_from_url(escrow.manifest).decode("utf-8") if EncryptionUtils.is_encrypted(manifest_content): encryption = Encryption( @@ -77,3 +78,14 @@ def get_available_webhook_types( (escrow.recording_oracle or "").lower(): OracleWebhookTypes.recording_oracle, (escrow.reputation_oracle or "").lower(): OracleWebhookTypes.reputation_oracle, } + + +def get_escrow_fund_token_symbol(chain_id: int, escrow_address: str) -> str: + escrow = get_escrow(chain_id, escrow_address) + + cache = Cache() + return cache.get_or_set_token_symbol( + chain_id=chain_id, + token_address=escrow.token, + set_callback=partial(get_token_symbol, chain_id, escrow.token), + ) diff --git a/packages/examples/cvat/exchange-oracle/src/chain/kvstore.py b/packages/examples/cvat/exchange-oracle/src/chain/kvstore.py index c22fa88cdd..4e89055904 100644 --- a/packages/examples/cvat/exchange-oracle/src/chain/kvstore.py +++ b/packages/examples/cvat/exchange-oracle/src/chain/kvstore.py @@ -1,6 +1,5 @@ -from human_protocol_sdk.constants import ChainId, KVStoreKeys +from human_protocol_sdk.constants import KVStoreKeys from human_protocol_sdk.kvstore import KVStoreClient, KVStoreClientError, KVStoreUtils -from human_protocol_sdk.operator import OperatorUtils from src.chain.escrow import get_escrow from src.chain.web3 import get_web3 @@ -13,7 +12,10 @@ def get_recording_oracle_url(chain_id: int, escrow_address: str) -> str: escrow = get_escrow(chain_id, escrow_address) - return OperatorUtils.get_operator(ChainId(chain_id), escrow.recording_oracle).webhook_url + # Subgraph can return invalid values, use KVStore itself + w3 = get_web3(chain_id) + kvstore_client = KVStoreClient(w3) + return kvstore_client.get(escrow.recording_oracle, "webhook_url") def get_reputation_oracle_url(chain_id: int, escrow_address: str) -> str: @@ -22,7 +24,10 @@ def get_reputation_oracle_url(chain_id: int, escrow_address: str) -> str: escrow = get_escrow(chain_id, escrow_address) - return OperatorUtils.get_operator(ChainId(chain_id), escrow.reputation_oracle).webhook_url + # Subgraph can return invalid values, use KVStore itself + w3 = get_web3(chain_id) + kvstore_client = KVStoreClient(w3) + return kvstore_client.get(escrow.reputation_oracle, "webhook_url") def get_job_launcher_url(chain_id: int, escrow_address: str) -> str: @@ -31,7 +36,10 @@ def get_job_launcher_url(chain_id: int, escrow_address: str) -> str: escrow = get_escrow(chain_id, escrow_address) - return OperatorUtils.get_operator(ChainId(chain_id), escrow.launcher).webhook_url + # Subgraph can return invalid values, use KVStore itself + w3 = get_web3(chain_id) + kvstore_client = KVStoreClient(w3) + return kvstore_client.get(escrow.launcher, "webhook_url") def register_in_kvstore() -> None: diff --git a/packages/examples/cvat/exchange-oracle/src/chain/web3.py b/packages/examples/cvat/exchange-oracle/src/chain/web3.py index b1783544d5..bc2f456d88 100644 --- a/packages/examples/cvat/exchange-oracle/src/chain/web3.py +++ b/packages/examples/cvat/exchange-oracle/src/chain/web3.py @@ -9,52 +9,40 @@ from src.core.config import Config from src.core.types import Networks +symbol_abi = [ + { + "constant": True, + "inputs": [], + "name": "symbol", + "outputs": [{"name": "", "type": "string"}], + "type": "function", + } +] # ABI for fetching token symbol -def get_web3(chain_id: Networks): + +def get_web3(chain_id: int | Networks): match chain_id: - case Config.polygon_mainnet.chain_id: - w3 = Web3(HTTPProvider(Config.polygon_mainnet.rpc_api)) - gas_payer = w3.eth.account.from_key(Config.polygon_mainnet.private_key) - w3.middleware_onion.inject( - SignAndSendRawMiddlewareBuilder.build(Config.polygon_mainnet.private_key), - "SignAndSendRawMiddlewareBuilder", - layer=0, - ) - w3.eth.default_account = gas_payer.address - return w3 - case Config.polygon_amoy.chain_id: - w3 = Web3(HTTPProvider(Config.polygon_amoy.rpc_api)) - gas_payer = w3.eth.account.from_key(Config.polygon_amoy.private_key) - w3.middleware_onion.inject( - SignAndSendRawMiddlewareBuilder.build(Config.polygon_amoy.private_key), - "SignAndSendRawMiddlewareBuilder", - layer=0, - ) - w3.eth.default_account = gas_payer.address - return w3 - case Config.aurora_testnet.chain_id: - w3 = Web3(HTTPProvider(Config.aurora_testnet.rpc_api)) - gas_payer = w3.eth.account.from_key(Config.aurora_testnet.private_key) - w3.middleware_onion.inject( - SignAndSendRawMiddlewareBuilder.build(Config.aurora_testnet.private_key), - "SignAndSendRawMiddlewareBuilder", - layer=0, - ) - w3.eth.default_account = gas_payer.address - return w3 - case Config.localhost.chain_id: - w3 = Web3(HTTPProvider(Config.localhost.rpc_api)) - gas_payer = w3.eth.account.from_key(Config.localhost.private_key) - w3.middleware_onion.inject( - SignAndSendRawMiddlewareBuilder.build(Config.localhost.private_key), - "SignAndSendRawMiddlewareBuilder", - layer=0, - ) - w3.eth.default_account = gas_payer.address - return w3 + case Networks.polygon_mainnet: + network = Config.polygon_mainnet + case Networks.polygon_amoy: + network = Config.polygon_amoy + case Networks.aurora_testnet: + network = Config.aurora_testnet + case Networks.localhost: + network = Config.localhost case _: raise ValueError(f"{chain_id} is not in available list of networks.") + w3 = Web3(HTTPProvider(network.rpc_api)) + gas_payer = w3.eth.account.from_key(network.private_key) + w3.middleware_onion.inject( + SignAndSendRawMiddlewareBuilder.build(network.private_key), + "SignAndSendRawMiddlewareBuilder", + layer=0, + ) + w3.eth.default_account = gas_payer.address + return w3 + def serialize_message(message: Any) -> str: return json.dumps(message, separators=(",", ":")) @@ -93,3 +81,9 @@ def validate_address(escrow_address: str) -> str: if not Web3.is_address(escrow_address): raise ValueError(f"{escrow_address} is not a correct Web3 address") return Web3.to_checksum_address(escrow_address) + + +def get_token_symbol(chain_id: int, token_address: str) -> str: + w3 = get_web3(chain_id) + contract = w3.eth.contract(address=w3.to_checksum_address(token_address), abi=symbol_abi) + return contract.functions.symbol().call() diff --git a/packages/examples/cvat/exchange-oracle/src/core/config.py b/packages/examples/cvat/exchange-oracle/src/core/config.py index 3b3dbad184..7807c583a2 100644 --- a/packages/examples/cvat/exchange-oracle/src/core/config.py +++ b/packages/examples/cvat/exchange-oracle/src/core/config.py @@ -126,27 +126,42 @@ class CronConfig: process_job_launcher_webhooks_chunk_size = int( getenv("PROCESS_JOB_LAUNCHER_WEBHOOKS_CHUNK_SIZE", 5) ) + process_recording_oracle_webhooks_int = int(getenv("PROCESS_RECORDING_ORACLE_WEBHOOKS_INT", 30)) process_recording_oracle_webhooks_chunk_size = int( getenv("PROCESS_RECORDING_ORACLE_WEBHOOKS_CHUNK_SIZE", 5) ) + process_reputation_oracle_webhooks_chunk_size = int( getenv("PROCESS_REPUTATION_ORACLE_WEBHOOKS_CHUNK_SIZE", 5) ) process_reputation_oracle_webhooks_int = int( getenv("PROCESS_REPUTATION_ORACLE_WEBHOOKS_INT", 5) ) + + process_cvat_webhooks_workers = int(getenv("PROCESS_CVAT_WEBHOOKS_WORKERS", 10)) + """ + The maximum number of parallel workers. Workers are added lazily, if the existing workers + can't finish in time. + """ + process_cvat_webhooks_int = int(getenv("PROCESS_CVAT_WEBHOOKS_INT", 5)) + process_cvat_webhooks_chunk_size = int(getenv("PROCESS_CVAT_WEBHOOKS_CHUNK_SIZE", 10)) + track_completed_projects_int = int(getenv("TRACK_COMPLETED_PROJECTS_INT", 30)) track_completed_tasks_int = int(getenv("TRACK_COMPLETED_TASKS_INT", 30)) + track_creating_tasks_int = int(getenv("TRACK_CREATING_TASKS_INT", 300)) track_creating_tasks_chunk_size = getenv("TRACK_CREATING_TASKS_CHUNK_SIZE", 5) + track_assignments_int = int(getenv("TRACK_ASSIGNMENTS_INT", 5)) track_assignments_chunk_size = int(getenv("TRACK_ASSIGNMENTS_CHUNK_SIZE", 10)) track_completed_escrows_int = int(getenv("TRACK_COMPLETED_ESCROWS_INT", 60)) track_completed_escrows_chunk_size = int(getenv("TRACK_COMPLETED_ESCROWS_CHUNK_SIZE", 100)) + track_escrow_validations_int = int(getenv("TRACK_ESCROW_VALIDATIONS_INT", 60)) track_escrow_validations_chunk_size = int(getenv("TRACK_ESCROW_VALIDATIONS_CHUNK_SIZE", 1)) + track_completed_escrows_max_downloading_retries = int( getenv("TRACK_COMPLETED_ESCROWS_MAX_DOWNLOADING_RETRIES", 10) ) @@ -240,7 +255,10 @@ class FeaturesConfig: "Allow to profile specific requests" manifest_cache_ttl = int(getenv("MANIFEST_CACHE_TTL", str(2 * 24 * 60 * 60))) - "TTL for cached manifests" + "TTL for cached manifests, in seconds" + + token_symbol_ttl = int(getenv("TOKEN_SYMBOL_CACHE_TTL", str(2 * 24 * 60 * 60))) + "TTL for cached token symbols, in seconds" max_data_storage_connections = int(getenv("MAX_DATA_STORAGE_CONNECTIONS", 5)) "Max parallel data storage connections in 1 client (job creation, ...)" diff --git a/packages/examples/cvat/exchange-oracle/src/core/types.py b/packages/examples/cvat/exchange-oracle/src/core/types.py index 5a27844fb4..3022f2efc3 100644 --- a/packages/examples/cvat/exchange-oracle/src/core/types.py +++ b/packages/examples/cvat/exchange-oracle/src/core/types.py @@ -75,6 +75,12 @@ class OracleWebhookStatuses(str, Enum, metaclass=BetterEnumMeta): failed = "failed" +class CvatWebhookStatuses(str, Enum, metaclass=BetterEnumMeta): + pending = "pending" + completed = "completed" + failed = "failed" + + class AssignmentStatuses(str, Enum, metaclass=BetterEnumMeta): """ State changes: diff --git a/packages/examples/cvat/exchange-oracle/src/crons/__init__.py b/packages/examples/cvat/exchange-oracle/src/crons/__init__.py index a640805474..c930b3b811 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/__init__.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/__init__.py @@ -3,6 +3,7 @@ from src.core.config import Config from src.crons.cvat.state_trackers import ( + process_incoming_cvat_webhooks, track_assignments, track_completed_escrows, track_completed_projects, @@ -57,6 +58,12 @@ def cron_record(): "interval", seconds=Config.cron_config.process_reputation_oracle_webhooks_int, ) + scheduler.add_job( + process_incoming_cvat_webhooks, + trigger="interval", + seconds=Config.cron_config.process_cvat_webhooks_int, + max_instances=Config.cron_config.process_cvat_webhooks_workers, + ) scheduler.add_job( track_completed_projects, "interval", diff --git a/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py b/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py index 213d129a7a..3eb027174a 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py @@ -1,4 +1,5 @@ import logging +from contextlib import suppress from sqlalchemy.orm import Session @@ -15,7 +16,30 @@ from src.db import errors as db_errors from src.db.utils import ForUpdateParams from src.handlers.completed_escrows import handle_escrows_validations +from src.handlers.cvat_events import cvat_webhook_handler from src.utils.logging import format_sequence +from src.utils.time import utcnow + + +@cron_job +def process_incoming_cvat_webhooks(logger: logging.Logger, session: Session) -> None: + webhooks = cvat_service.incoming_webhooks.get_pending_webhooks( + session=session, + limit=CronConfig.process_cvat_webhooks_chunk_size, + for_update=ForUpdateParams(skip_locked=True), + ) + + for webhook in webhooks: + try: + with session.begin_nested(): + cvat_webhook_handler(webhook, session) + cvat_service.incoming_webhooks.handle_webhook_success( + session, webhook_id=webhook.id + ) + except Exception as e: + with session.begin_nested(): + logger.exception(e) + cvat_service.incoming_webhooks.handle_webhook_fail(session, webhook_id=webhook.id) @cron_job @@ -53,18 +77,51 @@ def track_assignments(logger: logging.Logger) -> None: 4. If a project or task state is not "annotation", cancels assignments """ + def _try_complete_assignment( + session: Session, + assignment: cvat_models.Assignment, + ) -> bool: + """ + Checks if we haven't received a notification, but the job might have been completed. + + Returns: assignment completed + """ + + latest_assignment = cvat_service.get_latest_assignment_by_cvat_job_id( + session, assignment.cvat_job_id + ) + if not latest_assignment or latest_assignment.id != assignment.id: + return False + + try: + cvat_job = cvat_api.get_job(assignment.cvat_job_id) + except cvat_api.exceptions.NotFoundException: + return False + + if cvat_job.state != cvat_api.JobStatus.completed: + return False + + if not cvat_job.assignee or cvat_job.assignee.id != latest_assignment.user.cvat_id: + return False + + logger.info(f"Found completed job #{assignment.cvat_job_id}. Completing the assignment") + cvat_service.complete_assignment(session, assignment.id, completed_at=utcnow()) + cvat_api.update_job_assignee(assignment.cvat_job_id, assignee_id=None) + cvat_service.update_job_status(session, assignment.job.id, status=JobStatuses.completed) + cvat_service.touch(session, cvat_models.Job, [assignment.job.id]) + return True + def _reset_job_after_assignment(session: Session, assignment: cvat_models.Assignment): latest_assignment = cvat_service.get_latest_assignment_by_cvat_job_id( session, assignment.cvat_job_id ) - if latest_assignment.id == assignment.id: + if latest_assignment.id != assignment.id: # Avoid un-assigning if it's not the latest assignment + return - cvat_api.update_job_assignee( - assignment.cvat_job_id, assignee_id=None - ) # note that calling it in a loop can take too much time - - cvat_service.update_job_status(session, assignment.job.id, status=JobStatuses.new) + cvat_api.update_job_assignee(assignment.cvat_job_id, assignee_id=None) + cvat_service.update_job_status(session, assignment.job.id, status=JobStatuses.new) + cvat_service.touch(session, cvat_models.Job, [assignment.job.id]) with SessionLocal.begin() as session: assignments = cvat_service.get_unprocessed_expired_assignments( @@ -74,18 +131,27 @@ def _reset_job_after_assignment(session: Session, assignment: cvat_models.Assign ) for assignment in assignments: - logger.info( - "Expiring the unfinished assignment {} (user {}, job id {})".format( - assignment.id, - assignment.user_wallet_address, - assignment.cvat_job_id, + with ( + session.begin_nested(), + suppress(db_errors.LockNotAvailable), + ): + cvat_service.get_jobs_by_cvat_id( + session, + cvat_ids=[assignment.cvat_job_id], + for_update=ForUpdateParams(nowait=True), ) - ) - cvat_service.expire_assignment(session, assignment.id) - _reset_job_after_assignment(session, assignment) + if not _try_complete_assignment(session, assignment): + logger.info( + "Expiring the unfinished assignment {} (user {}, job id {})".format( + assignment.id, + assignment.user_wallet_address, + assignment.cvat_job_id, + ) + ) - cvat_service.touch(session, cvat_models.Job, [a.job.id for a in assignments]) + cvat_service.expire_assignment(session, assignment.id) + _reset_job_after_assignment(session, assignment) with SessionLocal.begin() as session: assignments = cvat_service.get_unprocessed_cancelled_assignments( @@ -95,16 +161,24 @@ def _reset_job_after_assignment(session: Session, assignment: cvat_models.Assign ) for assignment in assignments: - logger.info( - "Finalizing the canceled assignment {} (user {}, job id {})".format( - assignment.id, - assignment.user_wallet_address, - assignment.cvat_job_id, + with ( + session.begin_nested(), + suppress(db_errors.LockNotAvailable), + ): + cvat_service.get_jobs_by_cvat_id( + session, + cvat_ids=[assignment.cvat_job_id], + for_update=ForUpdateParams(nowait=True), ) - ) - _reset_job_after_assignment(session, assignment) - cvat_service.touch(session, cvat_models.Job, [a.job.id for a in assignments]) + logger.info( + "Finalizing the canceled assignment {} (user {}, job id {})".format( + assignment.id, + assignment.user_wallet_address, + assignment.cvat_job_id, + ) + ) + _reset_job_after_assignment(session, assignment) with SessionLocal.begin() as session: assignments = cvat_service.get_active_assignments( @@ -115,19 +189,27 @@ def _reset_job_after_assignment(session: Session, assignment: cvat_models.Assign for assignment in assignments: if assignment.job.project.status != ProjectStatuses.annotation: - logger.warning( - "Canceling the unfinished assignment {} (user {}, job id {}) - " - "the project state is not annotation".format( - assignment.id, - assignment.user_wallet_address, - assignment.cvat_job_id, + with ( + session.begin_nested(), + suppress(db_errors.LockNotAvailable), + ): + cvat_service.get_jobs_by_cvat_id( + session, + cvat_ids=[assignment.cvat_job_id], + for_update=ForUpdateParams(nowait=True), ) - ) - cvat_service.cancel_assignment(session, assignment.id) - _reset_job_after_assignment(session, assignment) + logger.warning( + "Canceling the unfinished assignment {} (user {}, job id {}) - " + "the project state is not annotation".format( + assignment.id, + assignment.user_wallet_address, + assignment.cvat_job_id, + ) + ) - cvat_service.touch(session, cvat_models.Job, [a.job.id for a in assignments]) + cvat_service.cancel_assignment(session, assignment.id) + _reset_job_after_assignment(session, assignment) @cron_job diff --git a/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py b/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py index 5f9f780043..1d4103b111 100644 --- a/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py +++ b/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py @@ -54,7 +54,6 @@ class LabelType(str, Enum, metaclass=BetterEnumMeta): class WebhookEventType(str, Enum, metaclass=BetterEnumMeta): update_job = "update:job" - create_job = "create:job" ping = "ping" @@ -324,8 +323,7 @@ def create_cvat_webhook(project_id: int) -> models.WebhookRead: # enable_ssl=True, project_id=project_id, events=[ - models.EventsEnum("update:job"), - models.EventsEnum("create:job"), + models.EventsEnum(WebhookEventType.update_job.value), ], ) # WebhookWriteRequest try: @@ -863,3 +861,14 @@ def remove_user_from_org(user_id: int): except exceptions.ApiException as e: logger.exception(f"Exception when calling remove_user_from_org: {e}\n") raise + + +def get_job(job_id: int) -> models.JobRead: + logger = logging.getLogger("app") + + with get_api_client() as api_client: + try: + return api_client.jobs_api.retrieve(job_id)[0] + except exceptions.ApiException as e: + logger.exception(f"Exception when calling get_job: {e}\n") + raise diff --git a/packages/examples/cvat/exchange-oracle/src/endpoints/authentication.py b/packages/examples/cvat/exchange-oracle/src/endpoints/authentication.py index 38ebcca10b..54d8039f4d 100644 --- a/packages/examples/cvat/exchange-oracle/src/endpoints/authentication.py +++ b/packages/examples/cvat/exchange-oracle/src/endpoints/authentication.py @@ -37,6 +37,10 @@ class AuthorizationData(BaseModel): email: str +class AssignmentAuthorizationData(AuthorizationData): + qualifications: list[str] + + AuthDataT = TypeVar("AuthDataT", bound=AuthorizationData) diff --git a/packages/examples/cvat/exchange-oracle/src/endpoints/cvat.py b/packages/examples/cvat/exchange-oracle/src/endpoints/cvat.py index 5e27735b6c..bcb6972d10 100644 --- a/packages/examples/cvat/exchange-oracle/src/endpoints/cvat.py +++ b/packages/examples/cvat/exchange-oracle/src/endpoints/cvat.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Header, Request -from src.handlers.cvat_events import cvat_webhook_handler +from src.handlers.cvat_events import cvat_webhook_request_handler from src.schemas.cvat import CvatWebhook from src.validators.signature import validate_cvat_signature @@ -16,4 +16,4 @@ async def receive_cvat_webhook( x_signature_256: Annotated[str, Header()], ): await validate_cvat_signature(request, x_signature_256) - cvat_webhook_handler(cvat_webhook) + cvat_webhook_request_handler(cvat_webhook) diff --git a/packages/examples/cvat/exchange-oracle/src/endpoints/exchange.py b/packages/examples/cvat/exchange-oracle/src/endpoints/exchange.py index 3e98a2915a..e75b14ddaf 100644 --- a/packages/examples/cvat/exchange-oracle/src/endpoints/exchange.py +++ b/packages/examples/cvat/exchange-oracle/src/endpoints/exchange.py @@ -18,6 +18,7 @@ from src.db import SessionLocal from src.db import engine as db_engine from src.endpoints.authentication import ( + AssignmentAuthorizationData, AuthorizationData, AuthorizationParam, JobListAuthorizationData, @@ -140,7 +141,12 @@ async def list_jobs( if status: match status: case JobStatuses.active: - query = query.filter(cvat_service.Project.status == ProjectStatuses.annotation) + query = query.filter( + cvat_service.Project.status == ProjectStatuses.annotation, + cvat_service.Project.jobs.any( + cvat_service.Job.status == cvat_service.JobStatuses.new + ), + ) case JobStatuses.canceled: query = query.filter( cvat_service.Project.status == cvat_service.ProjectStatuses.canceled @@ -390,17 +396,24 @@ def _page_serializer( description="Start an assignment within the task for the annotator", ) async def create_assignment( - data: AssignmentRequest, token: Annotated[AuthorizationData, AuthorizationParam] + data: AssignmentRequest, + token: Annotated[ + AssignmentAuthorizationData, make_auth_dependency(AssignmentAuthorizationData) + ], ) -> AssignmentResponse: try: assignment_id = oracle_service.create_assignment( escrow_address=data.escrow_address, chain_id=data.chain_id, wallet_address=token.wallet_address, + qualifications=token.qualifications, ) except oracle_service.UserHasUnfinishedAssignmentError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + except oracle_service.UserQualificationError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) from e + if not assignment_id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, diff --git a/packages/examples/cvat/exchange-oracle/src/endpoints/serializers.py b/packages/examples/cvat/exchange-oracle/src/endpoints/serializers.py index 74f96583de..83d4c9ee71 100644 --- a/packages/examples/cvat/exchange-oracle/src/endpoints/serializers.py +++ b/packages/examples/cvat/exchange-oracle/src/endpoints/serializers.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session import src.services.cvat as cvat_service -from src.chain.escrow import get_escrow_manifest +from src.chain.escrow import get_escrow_fund_token_symbol, get_escrow_manifest from src.core.manifest import TaskManifest from src.core.types import AssignmentStatuses, ProjectStatuses from src.db import SessionLocal @@ -52,6 +52,7 @@ def serialize_job( else: raise AssertionError(f"Unexpected project status '{project.status}'") + reward_token = get_escrow_fund_token_symbol(project.chain_id, project.escrow_address) return service_api.JobResponse( escrow_address=project.escrow_address, chain_id=project.chain_id, @@ -59,9 +60,7 @@ def serialize_job( status=api_status, job_description=manifest.annotation.description if manifest else None, reward_amount=str(manifest.job_bounty) if manifest else None, - reward_token=( - service_api.DEFAULT_TOKEN - ), # set a value to avoid being excluded by response_model_exclude_unset=True + reward_token=reward_token, created_at=project.created_at, updated_at=project.updated_at, qualifications=manifest.qualifications, @@ -126,6 +125,8 @@ def serialize_assignment( else: api_status = assignment_status_mapping[assignment.status] + reward_token = get_escrow_fund_token_symbol(project.chain_id, project.escrow_address) + return service_api.AssignmentResponse( assignment_id=assignment.id, escrow_address=project.escrow_address, @@ -133,9 +134,7 @@ def serialize_assignment( job_type=project.job_type, status=api_status, reward_amount=str(manifest.job_bounty) if manifest else None, - reward_token=( - service_api.DEFAULT_TOKEN - ), # set a value to avoid being excluded by response_model_exclude_unset=True + reward_token=reward_token, url=compose_assignment_url( task_id=assignment.job.cvat_task_id, job_id=assignment.cvat_job_id, diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py b/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py index ecec61ff7e..5072506b50 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py @@ -1,12 +1,15 @@ +from typing import Any + from dateutil.parser import parse as parse_aware_datetime +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session import src.cvat.api_calls as cvat_api -import src.models.cvat as models +import src.models.cvat as cvat_models import src.services.cvat as cvat_service -from src import db from src.core.types import AssignmentStatuses, JobStatuses, ProjectStatuses from src.db import SessionLocal -from src.db import errors as db_errors +from src.db.utils import ForUpdateParams from src.log import ROOT_LOGGER_NAME from src.schemas.cvat import CvatWebhook from src.utils.logging import get_function_logger @@ -14,84 +17,76 @@ module_logger_name = f"{ROOT_LOGGER_NAME}.cron.handler" -def handle_update_job_event(payload: dict) -> None: +def handle_update_job_event(payload: dict[str, Any], session: Session) -> None: logger = get_function_logger(module_logger_name) - if "state" not in payload.before_update: + if "state" not in payload.get("before_update", {}): return - new_cvat_status = cvat_api.JobStatus(payload.job["state"]) + new_cvat_status = cvat_api.JobStatus(payload["job"]["state"]) - with SessionLocal.begin() as session: - job_id = payload.job["id"] - jobs = cvat_service.get_jobs_by_cvat_id(session, [job_id], for_update=True) - if not jobs: - logger.warning( - f"Received a job update webhook for an unknown job id {job_id}, ignoring " - ) - return + job_id = payload["job"]["id"] + jobs = cvat_service.get_jobs_by_cvat_id(session, [job_id], for_update=True) + if not jobs: + logger.warning(f"Received a job update webhook for an unknown job id {job_id}, ignoring") + return - job = jobs[0] + job = jobs[0] - if job.status != JobStatuses.in_progress: - logger.warning( - f"Received a job update webhook for a job id {job_id} " - f"in the status {job.status}, ignoring " - ) - return + if job.status != JobStatuses.in_progress: + logger.warning( + f"Received a job update webhook for a job id {job_id} " + f"in the status {job.status}, ignoring" + ) + return - # ignore updates for any assignments except the last one - latest_assignment = cvat_service.get_latest_assignment_by_cvat_job_id( - session, job_id, for_update=True + if job.project.status != ProjectStatuses.annotation: + logger.warning( + f"Received a job update webhook for a job id {job_id} " + f"with the project in status {job.project.status}, ignoring" ) - if not latest_assignment: - logger.warning( - f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " - "No assignments for this job, ignoring the update" - ) - return - - webhook_time = parse_aware_datetime(payload.job["updated_date"]) - webhook_assignee_id = (payload.job["assignee"] or {}).get("id") - - matching_assignment = next( - ( - a - for a in [latest_assignment] - if a.user.cvat_id == webhook_assignee_id - if a.created_at < webhook_time - ), - None, + return + + # ignore updates for any assignments except the last one + latest_assignment = cvat_service.get_latest_assignment_by_cvat_job_id( + session, job_id, for_update=ForUpdateParams(nowait=True) + ) + if not latest_assignment: + logger.warning( + f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " + "No assignments for this job, ignoring the update" ) + return - if not matching_assignment: - logger.warning( - f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " - "No matching assignment or the assignment is too old, ignoring the update" - ) - elif matching_assignment.is_finished: - if matching_assignment.status == AssignmentStatuses.created: - logger.warning( - f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " - "Assignment is expired, rejecting the update" - ) - cvat_service.expire_assignment(session, matching_assignment.id) - - if matching_assignment.id == latest_assignment.id: - cvat_api.update_job_assignee(job.cvat_id, assignee_id=None) - cvat_service.update_job_status(session, job.id, status=JobStatuses.new) - - cvat_service.touch(session, models.Job, [job.id]) - else: - logger.info( - f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " - "Assignment is already finished, ignoring the update" - ) - elif ( - new_cvat_status == cvat_api.JobStatus.completed - and matching_assignment.id == latest_assignment.id - and matching_assignment.is_finished == False - ): + webhook_time = parse_aware_datetime(payload["job"]["updated_date"]) + webhook_assignee_id = (payload["job"]["assignee"] or {}).get("id") + + matching_assignment = next( + ( + a + for a in [latest_assignment] + if a.user.cvat_id == webhook_assignee_id + if a.created_at < webhook_time + ), + None, + ) + + if not matching_assignment: + logger.warning( + f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " + "No matching assignment or the assignment is too old, ignoring the update" + ) + elif matching_assignment.status != AssignmentStatuses.created: + logger.info( + f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " + "Assignment is already finished, ignoring the update" + ) + elif ( + new_cvat_status == cvat_api.JobStatus.completed + and matching_assignment.id == latest_assignment.id + ): + # Check that the webhook was issued before the assignment expiration + if webhook_time < matching_assignment.expires_at: logger.info( f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " "Completing the assignment" @@ -101,86 +96,65 @@ def handle_update_job_event(payload: dict) -> None: ) cvat_api.update_job_assignee(job.cvat_id, assignee_id=None) cvat_service.update_job_status(session, job.id, status=JobStatuses.completed) - cvat_service.touch(session, models.Job, [job.id]) else: - logger.info( + logger.warning( f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " - "Ignoring the update" + "Assignment is expired, rejecting the update" ) + cvat_service.expire_assignment(session, matching_assignment.id) + cvat_api.update_job_assignee(job.cvat_id, assignee_id=None) + cvat_service.update_job_status(session, job.id, status=JobStatuses.new) + cvat_service.touch(session, cvat_models.Job, [job.id]) + else: + logger.info( + f"Received job #{job.cvat_id} status update: {new_cvat_status.value}. " + "Ignoring the update" + ) -def handle_create_job_event(payload: dict) -> None: - logger = get_function_logger(module_logger_name) - - with SessionLocal.begin() as session: - if payload.job["type"] != "annotation": - return - - task_id = payload.job["task_id"] - if not cvat_service.get_tasks_by_cvat_id(session, [task_id], for_update=True): - logger.warning( - f"Received a job creation webhook for an unknown task id {task_id}, ignoring " - ) - return - jobs = cvat_service.get_jobs_by_cvat_id(session, [payload.job["id"]]) +def cvat_webhook_handler(cvat_webhook: cvat_models.CvatWebhook, session: Session) -> None: + match cvat_webhook.event_type: + case cvat_api.WebhookEventType.update_job.value: + handle_update_job_event(cvat_webhook.event_data, session) - if not jobs: - job_id = cvat_service.create_job( - session, - payload.job["id"], - payload.job["task_id"], - payload.job["project_id"], - status=JobStatuses[payload.job["state"]], - start_frame=payload.job["start_frame"], - stop_frame=payload.job["stop_frame"], - ) - cvat_service.touch(session, models.Job, [job_id]) - escrow_creation = None - with db.suppress(db_errors.LockNotAvailable): - projects = cvat_service.get_projects_by_cvat_ids( - session, project_cvat_ids=[payload.job["project_id"]], for_update=True - ) - if not projects: - return +def handle_update_job_event_request(payload: CvatWebhook) -> None: + if payload.job.get("type") != "annotation": + # We're not interested in any other job types so far + return - project = projects[0] + if "state" not in (payload.before_update or {}): + # We're only interested in state updates + return - escrow_creation = cvat_service.get_escrow_creation_by_escrow_address( + try: + with SessionLocal.begin() as session: + cvat_service.incoming_webhooks.create_webhook( session, - escrow_address=project.escrow_address, - chain_id=project.chain_id, - active=True, - for_update=True, + cvat_project_id=payload.job["project_id"], # all oracle jobs have project + cvat_task_id=payload.job["task_id"], + cvat_job_id=payload.job["id"], + event_type=payload.event, + event_data=payload.model_dump(), ) - - if not escrow_creation: - return - - created_jobs_count = cvat_service.count_jobs_by_escrow_address( - session, - escrow_address=escrow_creation.escrow_address, - chain_id=escrow_creation.chain_id, - status=JobStatuses.new, - ) - - if created_jobs_count != escrow_creation.total_jobs: - return - - cvat_service.update_project_statuses_by_escrow_address( - session=session, - escrow_address=escrow_creation.escrow_address, - chain_id=escrow_creation.chain_id, - status=ProjectStatuses.annotation, - ) + except IntegrityError as e: + if "is not present in table" in str(e.orig): + logger = get_function_logger(module_logger_name) + logger.warning( + f"Received a webhook event '{payload.event}' for " + f"project_id={payload.job['project_id']} " + f"task_id={payload.job['task_id']} " + f"job_id={payload.job['id']}. " + "The corresponding object doesn't exist in the DB, ignoring" + ) + else: + raise -def cvat_webhook_handler(cvat_webhook: CvatWebhook) -> None: +def cvat_webhook_request_handler(cvat_webhook: CvatWebhook) -> None: match cvat_webhook.event: case cvat_api.WebhookEventType.update_job.value: - handle_update_job_event(cvat_webhook) - case cvat_api.WebhookEventType.create_job.value: - handle_create_job_event(cvat_webhook) + handle_update_job_event_request(cvat_webhook) case cvat_api.WebhookEventType.ping.value: pass diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py b/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py index f4cdb1a026..f83339675b 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/escrow_cleanup.py @@ -87,6 +87,9 @@ def _cleanup_db(session: Session, escrow_address: str, chain_id: int) -> None: cvat_db_service.remove_escrow_images( session=session, escrow_address=escrow_address, chain_id=chain_id ) + cvat_db_service.clear_escrow_webhooks( + session=session, escrow_address=escrow_address, chain_id=chain_id + ) def cleanup_escrow( diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py b/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py index b206ca71fa..666f71e2a8 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py @@ -383,7 +383,7 @@ def build(self): user_guide=manifest.annotation.user_guide, ) - # Setup webhooks for a project (update:task, update:job) + # Setup webhooks for the project cvat_webhook = cvat_api.create_cvat_webhook(cvat_project.id) with SessionLocal.begin() as session: @@ -1545,7 +1545,7 @@ def _create_on_cvat(self): user_guide=self.manifest.annotation.user_guide, ) - # Setup webhooks for a project (update:task, update:job) + # Setup webhooks for the project cvat_webhook = cvat_api.create_cvat_webhook(cvat_project.id) with SessionLocal.begin() as session: @@ -2907,7 +2907,7 @@ def _task_params_label_key(ts): # TODO: improve guide handling - split for different points ) - # Setup webhooks for a project (update:task, update:job) + # Setup webhooks for the project cvat_webhook = cvat_api.create_cvat_webhook(cvat_project.id) project_id = db_service.create_project( diff --git a/packages/examples/cvat/exchange-oracle/src/models/cvat.py b/packages/examples/cvat/exchange-oracle/src/models/cvat.py index b31f687e30..dd0eb2d715 100644 --- a/packages/examples/cvat/exchange-oracle/src/models/cvat.py +++ b/packages/examples/cvat/exchange-oracle/src/models/cvat.py @@ -1,12 +1,13 @@ # pylint: disable=too-few-public-methods from __future__ import annotations -from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import JSON, Column, DateTime, Enum, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, relationship from sqlalchemy.sql import func from src.core.types import ( AssignmentStatuses, + CvatWebhookStatuses, EscrowValidationStatuses, JobStatuses, Networks, @@ -49,6 +50,12 @@ class Project(BaseUUID): passive_deletes=True, ) + cvat_webhooks: Mapped[list[CvatWebhook]] = relationship( + back_populates="project", + cascade="all, delete", + passive_deletes=True, + ) + escrow_creation: Mapped[EscrowCreation] = relationship( back_populates="projects", passive_deletes=True, @@ -101,6 +108,11 @@ class Task(ChildOf[Project]): data_upload: Mapped[DataUpload] = relationship( back_populates="task", cascade="all, delete", passive_deletes=True ) + cvat_webhooks: Mapped[list[CvatWebhook]] = relationship( + back_populates="task", + cascade="all, delete", + passive_deletes=True, + ) def __repr__(self) -> str: return f"Task. id={self.id}" @@ -198,6 +210,11 @@ class Job(ChildOf[Task]): passive_deletes=True, order_by="desc(Assignment.created_at)", ) + cvat_webhooks: Mapped[list[CvatWebhook]] = relationship( + back_populates="job", + cascade="all, delete", + passive_deletes=True, + ) @property def latest_assignment(self) -> Assignment | None: @@ -275,3 +292,39 @@ def __repr__(self) -> str: return ( f"Image. id={self.id} cvat_project_id={self.cvat_project_id} filename={self.filename}" ) + + +class CvatWebhook(BaseUUID): + __tablename__ = "cvat_webhooks" + cvat_project_id = Column( + Integer, + ForeignKey("projects.cvat_id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + cvat_task_id = Column( + Integer, ForeignKey("tasks.cvat_id", ondelete="CASCADE"), nullable=False, index=True + ) + cvat_job_id = Column( + Integer, ForeignKey("jobs.cvat_id", ondelete="CASCADE"), nullable=False, index=True + ) + + status = Column( + String, + Enum(CvatWebhookStatuses), + server_default=CvatWebhookStatuses.pending.value, + ) + attempts = Column(Integer, server_default="0") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + wait_until = Column(DateTime(timezone=True), server_default=func.now(), default=utcnow) + + event_type = Column(String, nullable=False) + event_data = Column(JSON, nullable=True, server_default=None) + + project: Mapped[Project] = relationship(back_populates="cvat_webhooks") + task: Mapped[Task] = relationship(back_populates="cvat_webhooks") + job: Mapped[Job] = relationship(back_populates="cvat_webhooks") + + def __repr__(self) -> str: + return f"CvatWebhook. id={self.id} type={self.event_type}" diff --git a/packages/examples/cvat/exchange-oracle/src/schemas/exchange.py b/packages/examples/cvat/exchange-oracle/src/schemas/exchange.py index 6236fb6677..73fde0d230 100644 --- a/packages/examples/cvat/exchange-oracle/src/schemas/exchange.py +++ b/packages/examples/cvat/exchange-oracle/src/schemas/exchange.py @@ -7,8 +7,6 @@ from src.core.types import Networks, TaskTypes from src.utils.enums import BetterEnumMeta -DEFAULT_TOKEN = "HMT" # noqa: S105 (it's not a credential) - class JobStatuses(StrEnum, metaclass=BetterEnumMeta): active = "active" @@ -23,7 +21,7 @@ class JobResponse(BaseModel): status: JobStatuses job_description: str | None = None reward_amount: str | None = None - reward_token: str | None = DEFAULT_TOKEN + reward_token: str | None = None created_at: datetime | None = None updated_at: datetime | None = None qualifications: list[str] = Field(default_factory=list) @@ -60,7 +58,7 @@ class AssignmentResponse(BaseModel): url: str | None status: AssignmentStatuses reward_amount: str | None = None - reward_token: str | None = DEFAULT_TOKEN + reward_token: str | None = None created_at: datetime updated_at: datetime expires_at: datetime diff --git a/packages/examples/cvat/exchange-oracle/src/services/cache.py b/packages/examples/cvat/exchange-oracle/src/services/cache.py index c706063efb..2a6d93f622 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/cache.py +++ b/packages/examples/cvat/exchange-oracle/src/services/cache.py @@ -84,8 +84,8 @@ def _get_cache(self) -> BaseCache: return get_cache() @staticmethod - def _make_key(escrow_address: str, chain_id: int) -> str: - return f"{escrow_address}@{chain_id}" + def _make_key(address: str, chain_id: int) -> str: + return f"{address}@{chain_id}" def _get_or_set(self, key: str, set_callback, *, ttl: int | None = None): cache = self._get_cache() @@ -105,3 +105,10 @@ def get_or_set_manifest( kwargs.setdefault("ttl", Config.features.manifest_cache_ttl) key = self._make_key(escrow_address, chain_id) return self._get_or_set(key, set_callback=set_callback, **kwargs) + + def get_or_set_token_symbol( + self, chain_id: int, token_address: str, *, set_callback: Callable[[], str], **kwargs + ) -> str: + kwargs.setdefault("ttl", Config.features.token_symbol_ttl) + key = self._make_key(token_address, chain_id) + return self._get_or_set(key, set_callback=set_callback, **kwargs) diff --git a/packages/examples/cvat/exchange-oracle/src/services/cvat.py b/packages/examples/cvat/exchange-oracle/src/services/cvat.py index 09768227dd..083b1e8efc 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/cvat.py +++ b/packages/examples/cvat/exchange-oracle/src/services/cvat.py @@ -5,7 +5,7 @@ import itertools import uuid from collections.abc import Iterable, Sequence -from datetime import datetime +from datetime import datetime, timedelta from itertools import islice from typing import Any, NamedTuple @@ -14,8 +14,10 @@ from sqlalchemy.orm import Session from sqlalchemy.sql.functions import coalesce +from src.core.config import Config from src.core.types import ( AssignmentStatuses, + CvatWebhookStatuses, EscrowValidationStatuses, JobStatuses, ProjectStatuses, @@ -28,6 +30,7 @@ from src.db.utils import maybe_for_update as _maybe_for_update from src.models.cvat import ( Assignment, + CvatWebhook, DataUpload, EscrowCreation, EscrowValidation, @@ -869,6 +872,11 @@ def get_unprocessed_expired_assignments( (Assignment.status == AssignmentStatuses.created.value) & (Assignment.completed_at == None) & (Assignment.expires_at <= utcnow()) + & ( + ~Assignment.job.has( + Job.cvat_webhooks.any(CvatWebhook.status == CvatWebhookStatuses.pending) + ) + ) ) .limit(limit) .all() @@ -883,6 +891,11 @@ def get_unprocessed_cancelled_assignments( .where( (Assignment.job.has(Job.status == JobStatuses.in_progress.value)) & (Assignment.status == AssignmentStatuses.canceled.value) + & ( + ~Assignment.job.has( + Job.cvat_webhooks.any(CvatWebhook.status == CvatWebhookStatuses.pending) + ) + ) ) .limit(limit) .all() @@ -960,7 +973,7 @@ def get_user_assignments_in_cvat_projects( def has_active_user_assignments( session: Session, - wallet_address: int, + wallet_address: str, escrow_address: str, chain_id: int, ) -> bool: @@ -1117,3 +1130,95 @@ def touch_final_assignments( if touch_parents: touch_parent_objects(session, Assignment, ids.scalars().all(), time=time) + + +# Webhooks + + +def clear_escrow_webhooks(session: Session, escrow_address: str, chain_id: int): + session.execute( + delete(CvatWebhook).where( + CvatWebhook.project.has( + (Project.escrow_address == escrow_address) & (Project.chain_id == chain_id) + ) + ) + ) + + +class CvatWebhookQueue: + def create_webhook( + self, + session: Session, + *, + event_type: str, + event_data: dict[str, Any], + cvat_project_id: int, + cvat_task_id: int, + cvat_job_id: int, + ) -> str: + """ + Creates a webhook in a database. + """ + webhook_id = str(uuid.uuid4()) + webhook = CvatWebhook( + id=webhook_id, + event_type=event_type, + event_data=event_data, + cvat_project_id=cvat_project_id, + cvat_task_id=cvat_task_id, + cvat_job_id=cvat_job_id, + ) + session.add(webhook) + + return webhook_id + + def get_pending_webhooks( + self, + session: Session, + *, + event_type_in: Sequence[str] | None = None, + event_type_not_in: Sequence[str] | None = None, + limit: int = 10, + for_update: bool | ForUpdateParams = False, + ) -> list[CvatWebhook]: + assert not ( + event_type_in and event_type_not_in + ), f"{event_type_in} and {event_type_not_in} cannot be used together" + + return ( + _maybe_for_update(session.query(CvatWebhook), enable=for_update) + .where( + CvatWebhook.status == CvatWebhookStatuses.pending.value, + CvatWebhook.wait_until <= utcnow(), + *([CvatWebhook.event_type.in_(event_type_in)] if event_type_in else []), + *([CvatWebhook.event_type.not_in(event_type_not_in)] if event_type_not_in else []), + ) + .limit(limit) + .all() + ) + + def handle_webhook_success(self, session: Session, webhook_id: str) -> None: + upd = ( + update(CvatWebhook) + .where(CvatWebhook.id == webhook_id) + .values( + attempts=CvatWebhook.attempts + 1, + status=CvatWebhookStatuses.completed, + ) + ) + session.execute(upd) + + def handle_webhook_fail(self, session: Session, webhook_id: str) -> None: + upd = ( + update(CvatWebhook) + .where(CvatWebhook.id == webhook_id) + .values( + attempts=CvatWebhook.attempts + 1, + # no automatic failures by max attempts for CVAT webhooks + wait_until=utcnow() + timedelta(seconds=Config.webhook_delay_if_failed), + ) + ) + session.execute(upd) + + +incoming_webhooks = CvatWebhookQueue() diff --git a/packages/examples/cvat/exchange-oracle/src/services/exchange.py b/packages/examples/cvat/exchange-oracle/src/services/exchange.py index 7d514597a0..5d6d1b3dda 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/exchange.py +++ b/packages/examples/cvat/exchange-oracle/src/services/exchange.py @@ -3,11 +3,12 @@ import src.cvat.api_calls as cvat_api import src.services.cvat as cvat_service +from src.chain.escrow import get_escrow_manifest from src.core.types import JobStatuses, Networks, ProjectStatuses, TaskTypes from src.db import SessionLocal from src.db.utils import ForUpdateParams from src.models.cvat import Job -from src.utils.assignments import get_default_assignment_timeout +from src.utils.assignments import get_default_assignment_timeout, parse_manifest from src.utils.requests import get_or_404 from src.utils.time import utcnow @@ -20,7 +21,14 @@ def __str__(self) -> str: ) -def create_assignment(escrow_address: str, chain_id: Networks, wallet_address: str) -> str | None: +class UserQualificationError(Exception): + def __str__(self) -> str: + return "User doesn't have required qualifications." + + +def create_assignment( + escrow_address: str, chain_id: Networks, wallet_address: str, qualifications: list[str] +) -> str | None: with SessionLocal.begin() as session: user = get_or_404( cvat_service.get_user_by_id(session, wallet_address, for_update=True), @@ -28,6 +36,11 @@ def create_assignment(escrow_address: str, chain_id: Networks, wallet_address: s object_type_name="user", ) + manifest = parse_manifest(get_escrow_manifest(chain_id, escrow_address)) + + if not all(q in qualifications for q in manifest.qualifications): + raise UserQualificationError + if cvat_service.has_active_user_assignments( session, wallet_address=wallet_address, diff --git a/packages/examples/cvat/exchange-oracle/tests/api/test_cvat_webhook_api.py b/packages/examples/cvat/exchange-oracle/tests/api/test_cvat_webhook_api.py index 3c54224f69..e177648232 100644 --- a/packages/examples/cvat/exchange-oracle/tests/api/test_cvat_webhook_api.py +++ b/packages/examples/cvat/exchange-oracle/tests/api/test_cvat_webhook_api.py @@ -1,20 +1,16 @@ -from datetime import datetime, timedelta -from unittest.mock import patch +from datetime import timedelta -import pytest from fastapi.testclient import TestClient -from src.core.types import AssignmentStatuses, JobStatuses +from src.models.cvat import CvatWebhook from src.utils.time import utcnow -from tests.utils.constants import WALLET_ADDRESS1, WALLET_ADDRESS2 from tests.utils.setup_cvat import ( - add_assignment_to_db, add_cvat_job_to_db, add_cvat_project_to_db, add_cvat_task_to_db, generate_cvat_signature, - get_cvat_job_from_db, + get_session, ) API_URL = "http://localhost:8080/api/" @@ -35,69 +31,19 @@ def test_ping_incoming_webhook(client: TestClient) -> None: assert response.status_code == 200 -def test_incoming_webhook_200(client: TestClient) -> None: - # Create some entities in test DB +def test_can_accept_incoming_job_update_webhook(client: TestClient) -> None: add_cvat_project_to_db(cvat_id=1) add_cvat_task_to_db(cvat_id=1, cvat_project_id=1) - - # Payload for "create:job" event - data = { - "event": "create:job", - "job": { - "url": API_URL + "jobs/1", - "id": 1, - "task_id": 1, - "project_id": 1, - "state": "new", - "type": "annotation", - "start_frame": 0, - "stop_frame": 1, - }, - "webhook_id": 1, - } - - signature = generate_cvat_signature(data) - - # Check if "create:job" event works correctly - response = client.post( - "/cvat-webhook", - headers={"X-Signature-256": signature}, - json=data, - ) - - assert response.status_code == 200 - - (job, _) = get_cvat_job_from_db(1) - assert job.cvat_id == 1 - assert job.cvat_task_id == 1 - assert job.cvat_project_id == 1 - - -@pytest.mark.parametrize("is_last_assignment", [True, False]) -def test_incoming_webhook_can_update_expired_assignment( - client: TestClient, is_last_assignment: bool -): - # Check if an "update:job" event can update an expired assignment, - # if the assignment is the last one for the job. Updates to other assignments should be ignored. - - add_cvat_project_to_db(cvat_id=1) - add_cvat_task_to_db(cvat_id=1, cvat_project_id=1) - job = add_cvat_job_to_db( - cvat_id=1, cvat_task_id=1, cvat_project_id=1, status=JobStatuses.in_progress - ) + add_cvat_job_to_db(cvat_id=1, cvat_task_id=1, cvat_project_id=1) user_cvat_id = 1 - add_assignment_to_db(WALLET_ADDRESS1, user_cvat_id, job.cvat_id, expires_at=utcnow()) - - if not is_last_assignment: - user_cvat_id += 1 - add_assignment_to_db(WALLET_ADDRESS2, user_cvat_id, job.cvat_id, expires_at=utcnow()) data = { "event": "update:job", "job": { "url": API_URL + "jobs/1", "id": 1, + "type": "annotation", "task_id": 1, "project_id": 1, "state": "completed", @@ -113,82 +59,16 @@ def test_incoming_webhook_can_update_expired_assignment( "webhook_id": 1, } - with patch("src.handlers.cvat_events.cvat_api.update_job_assignee") as mock_update_job_assignee: - response = client.post( - "/cvat-webhook", - headers={"X-Signature-256": generate_cvat_signature(data)}, - json=data, - ) - - assert response.status_code == 200 - - (job, assignments) = get_cvat_job_from_db(1) - assert job.status == JobStatuses.new - assert assignments[-1].status == AssignmentStatuses.expired - mock_update_job_assignee.assert_called_once_with(job.cvat_id, assignee_id=None) - - if not is_last_assignment: - for assignment in assignments[:-1]: - assert assignment.status == AssignmentStatuses.created - - -@pytest.mark.parametrize("assignment_status", AssignmentStatuses) -def test_incoming_webhook_can_update_active_assignment( - client: TestClient, assignment_status: AssignmentStatuses -): - add_cvat_project_to_db(cvat_id=1) - add_cvat_task_to_db(cvat_id=1, cvat_project_id=1) - job = add_cvat_job_to_db( - cvat_id=1, cvat_task_id=1, cvat_project_id=1, status=JobStatuses.in_progress - ) - add_assignment_to_db( - WALLET_ADDRESS1, - 1, - job.cvat_id, - status=assignment_status, - expires_at=datetime.now() - if assignment_status == AssignmentStatuses.expired - else datetime.now() + timedelta(hours=1), + response = client.post( + "/cvat-webhook", + headers={"X-Signature-256": generate_cvat_signature(data)}, + json=data, ) - data = { - "event": "update:job", - "job": { - "url": API_URL + "jobs/1", - "id": 1, - "task_id": 1, - "project_id": 1, - "state": "completed", - "start_frame": 0, - "stop_frame": 1, - "assignee": { - "url": API_URL + "users/1", - "id": 1, - }, - "updated_date": utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z", - }, - "before_update": {"state": "in_progress", "assignee": None}, - "webhook_id": 1, - } - - with patch("src.handlers.cvat_events.cvat_api.update_job_assignee") as mock_update_job_assignee: - response = client.post( - "/cvat-webhook", - headers={"X-Signature-256": generate_cvat_signature(data)}, - json=data, - ) - assert response.status_code == 200 - (job, assignments) = get_cvat_job_from_db(1) - if assignment_status == AssignmentStatuses.created: - assert job.status == JobStatuses.completed - assert assignments[0].status == AssignmentStatuses.completed - mock_update_job_assignee.assert_called_once_with(job.cvat_id, assignee_id=None) - else: - assert job.status == JobStatuses.in_progress - assert assignments[0].status == assignment_status - mock_update_job_assignee.assert_not_called() + with get_session() as session: + assert len(session.query(CvatWebhook).all()) == 1 def test_incoming_webhook_401_bad_signature(client: TestClient) -> None: diff --git a/packages/examples/cvat/exchange-oracle/tests/api/test_exchange_api.py b/packages/examples/cvat/exchange-oracle/tests/api/test_exchange_api.py index a310398829..71c812bc52 100644 --- a/packages/examples/cvat/exchange-oracle/tests/api/test_exchange_api.py +++ b/packages/examples/cvat/exchange-oracle/tests/api/test_exchange_api.py @@ -57,11 +57,15 @@ def generate_jwt_token( *, wallet_address: str | None = None, email: str = cvat_email, + qualifications: list[str] | None = None, private_key: str = PRIVATE_KEY, ) -> str: + if qualifications is None: + qualifications = [] data = { **({"wallet_address": wallet_address} if wallet_address else {"role": "human_app"}), "email": email, + "qualifications": qualifications, } return jwt.encode(data, private_key, algorithm="ES256") @@ -172,9 +176,13 @@ def validate_result( with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" # check default pagination parameters response = client.get( @@ -233,9 +241,13 @@ def test_can_list_jobs_200_without_escrows_in_hidden_states( with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" response = client.get( "/job", @@ -287,9 +299,13 @@ def test_can_list_jobs_200_with_only_one_entry_per_escrow_address_if_several_pro with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" response = client.get( "/job", @@ -323,9 +339,13 @@ def test_can_list_jobs_200_with_fields(client: TestClient, session: Session) -> with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" required_fields = { "escrow_address", @@ -402,9 +422,13 @@ def test_can_list_jobs_200_with_sorting(client: TestClient, session: Session) -> with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" for sort_field, case_converter in product( ( @@ -518,9 +542,13 @@ def test_can_list_jobs_200_with_filters(client: TestClient, session: Session): with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" for filter_key, filter_values in { "status": ( @@ -564,6 +592,66 @@ def test_can_list_jobs_200_with_filters(client: TestClient, session: Session): ) +def test_can_list_jobs_200_can_show_only_active_jobs_with_free_assignments( + client: TestClient, session: Session +): + session.begin() + + user = User( + wallet_address=WALLET_ADDRESS1, + cvat_email=cvat_email, + cvat_id=1, + ) + session.add(user) + + cvat_project1, _, _ = create_project_task_and_job( + session, "0x86e83d346041E8806e352681f3F14549C0d2001", 1 + ) + cvat_project1.status = ProjectStatuses.annotation + session.add(cvat_project1) + + cvat_project2, _, cvat_job2 = create_project_task_and_job( + session, "0x86e83d346041E8806e352681f3F14549C0d2002", 2 + ) + cvat_project2.status = ProjectStatuses.annotation + cvat_job2.status = JobStatuses.in_progress + session.add(cvat_project2) + session.add(cvat_job2) + + assignment = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=user.wallet_address, + cvat_job_id=cvat_job2.cvat_id, + status=AssignmentStatuses.created, + created_at=utcnow() - timedelta(hours=1), + expires_at=utcnow() + timedelta(hours=1), + ) + session.add(assignment) + + session.commit() + + with ( + open("tests/utils/manifest.json") as data, + patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" + + response = client.get( + "/job", + headers=get_auth_header(token=generate_jwt_token()), + params={"status": APIJobStatuses.active.value}, + ) + + assert response.status_code == 200 + paginated_result = response.json() + assert paginated_result["total_results"] == 1 + + def test_can_list_jobs_200_check_values(client: TestClient, session: Session) -> None: session.begin() user = User( @@ -594,9 +682,13 @@ def test_can_list_jobs_200_check_values(client: TestClient, session: Session) -> with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" response = client.get( "/job", @@ -649,9 +741,13 @@ def test_can_list_jobs_200_without_address(client: TestClient, session: Session) with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" response = client.get( "/job", @@ -779,11 +875,17 @@ def test_can_create_assignment_200(client: TestClient, session: Session) -> None with ( open("tests/utils/manifest.json") as data, - patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch("src.endpoints.serializers.get_escrow_manifest") as mock_serializer_get_manifest, + patch("src.services.exchange.get_escrow_manifest") as mock_exchange_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, patch("src.services.exchange.cvat_api") as cvat_api, ): manifest = json.load(data) - mock_get_manifest.return_value = manifest + mock_serializer_get_manifest.return_value = manifest + mock_exchange_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" assert {cvat_project.updated_at, cvat_task.updated_at, cvat_job.updated_at} == {None} response = client.post( @@ -876,17 +978,23 @@ def test_cannot_create_assignment_400_when_has_unfinished_assignments( session.commit() - response = client.post( - "/assignment", - headers=get_auth_header(), - json={ - "escrow_address": cvat_project.escrow_address, - "chain_id": cvat_project.chain_id, - }, - ) + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest + response = client.post( + "/assignment", + headers=get_auth_header(), + json={ + "escrow_address": cvat_project.escrow_address, + "chain_id": cvat_project.chain_id, + }, + ) - assert response.status_code == 400 - assert "There are unfinished assignments in this escrow" in response.text + assert response.status_code == 400 + assert "There are unfinished assignments in this escrow" in response.text def test_can_list_assignments_200(client: TestClient, session: Session) -> None: @@ -968,9 +1076,13 @@ def test_can_list_assignments_200(client: TestClient, session: Session) -> None: with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" for filter_key, filter_values in { "status": ( @@ -1045,9 +1157,13 @@ def test_can_list_assignments_200_with_sorting(client: TestClient, session: Sess with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" for sort_field, case_converter in product( ( @@ -1397,11 +1513,17 @@ def test_can_list_jobs_200_check_updated_at(client: TestClient, session: Session with ( open("tests/utils/manifest.json") as data, - patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch("src.endpoints.serializers.get_escrow_manifest") as mock_serializer_get_manifest, + patch("src.services.exchange.get_escrow_manifest") as mock_exchange_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, patch("src.services.exchange.cvat_api"), ): manifest = json.load(data) - mock_get_manifest.return_value = manifest + mock_serializer_get_manifest.return_value = manifest + mock_exchange_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" # create assignment in each job for i in range(jobs_count): diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_escrow.py b/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_escrow.py index 2d69ea7813..c2569a6861 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_escrow.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_escrow.py @@ -15,7 +15,6 @@ from src.core.types import OracleWebhookTypes from tests.utils.constants import ( - DEFAULT_MANIFEST_URL, ESCROW_ADDRESS, EXCHANGE_ORACLE_ADDRESS, FACTORY_ADDRESS, @@ -48,7 +47,6 @@ def setUp(self): token=TOKEN_ADDRESS, total_funded_amount=1000, created_at="", - manifest_url=DEFAULT_MANIFEST_URL, recording_oracle=RECORDING_ORACLE_ADDRESS, exchange_oracle=EXCHANGE_ORACLE_ADDRESS, reputation_oracle=REPUTATION_ORACLE_ADDRESS, diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_kvstore.py b/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_kvstore.py index 1ba3963fdf..86438fbedb 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_kvstore.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/chain/test_kvstore.py @@ -1,5 +1,4 @@ -import unittest -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest from human_protocol_sdk.constants import ChainId, Status @@ -20,14 +19,16 @@ FACTORY_ADDRESS, JOB_LAUNCHER_ADDRESS, RECORDING_ORACLE_ADDRESS, + REPUTATION_ORACLE_ADDRESS, TOKEN_ADDRESS, ) escrow_address = ESCROW_ADDRESS -class ServiceIntegrationTest(unittest.TestCase): - def setUp(self): +class ServiceIntegrationTest: + @pytest.fixture(autouse=True) + def setup(self): self.w3 = Mock() self.w3.eth.chain_id = ChainId.LOCALHOST.value self.escrow_data = EscrowData( @@ -43,59 +44,30 @@ def setUp(self): token=TOKEN_ADDRESS, total_funded_amount=1000, created_at="", - manifest_url=DEFAULT_MANIFEST_URL, recording_oracle=RECORDING_ORACLE_ADDRESS, + reputation_oracle=REPUTATION_ORACLE_ADDRESS, ) - def test_get_job_launcher_url(self): + @pytest.mark.parametrize( + "get_url", [get_reputation_oracle_url, get_job_launcher_url, get_recording_oracle_url] + ) + def test_get_oracle_url(self, get_url): with ( patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, + patch("src.chain.kvstore.get_web3", return_value=self.w3), + patch("src.chain.kvstore.KVStoreClient.get") as mock_kvstore_get, ): mock_escrow.return_value = self.escrow_data - mock_operator.return_value = MagicMock(webhook_url=DEFAULT_MANIFEST_URL) - recording_url = get_job_launcher_url(self.w3.eth.chain_id, escrow_address) - assert recording_url == DEFAULT_MANIFEST_URL - - def test_get_job_launcher_url_invalid_escrow(self): - with pytest.raises(EscrowClientError, match="Invalid escrow address: invalid_address"): - get_job_launcher_url(self.w3.eth.chain_id, "invalid_address") - - def test_get_job_launcher_url_invalid_recording_address(self): - with ( - patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, - ): - mock_escrow.return_value = self.escrow_data - mock_operator.return_value = MagicMock(webhook_url="") - recording_url = get_job_launcher_url(self.w3.eth.chain_id, escrow_address) - assert recording_url == "" - - def test_get_recording_oracle_url(self): - with ( - patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, - ): - self.escrow_data.recording_oracle = RECORDING_ORACLE_ADDRESS - mock_escrow.return_value = self.escrow_data - mock_operator.return_value = MagicMock(webhook_url=DEFAULT_MANIFEST_URL) - recording_url = get_recording_oracle_url(self.w3.eth.chain_id, escrow_address) - assert recording_url == DEFAULT_MANIFEST_URL - - def test_get_recording_oracle_url_invalid_escrow(self): + mock_kvstore_get.return_value = DEFAULT_MANIFEST_URL + actual_url = get_url(self.w3.eth.chain_id, escrow_address) + assert actual_url == DEFAULT_MANIFEST_URL + + @pytest.mark.parametrize( + "get_url", [get_reputation_oracle_url, get_job_launcher_url, get_recording_oracle_url] + ) + def test_get_oracle_url_invalid_escrow(self, get_url): with pytest.raises(EscrowClientError, match="Invalid escrow address: invalid_address"): - get_recording_oracle_url(self.w3.eth.chain_id, "invalid_address") - - def test_get_recording_oracle_url_invalid_recording_address(self): - with ( - patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, - ): - self.escrow_data.recording_oracle = RECORDING_ORACLE_ADDRESS - mock_escrow.return_value = self.escrow_data - mock_operator.return_value = MagicMock(webhook_url="") - recording_url = get_recording_oracle_url(self.w3.eth.chain_id, escrow_address) - assert recording_url == "" + get_url(self.w3.eth.chain_id, "invalid_address") def test_store_public_key(self): PGP_PUBLIC_KEY_URL_1 = "http://pgp-public-key-url-1" @@ -199,25 +171,3 @@ def set_file_url_and_hash(url: str, key: str): ) == PGP_PUBLIC_KEY_URL_2 ) - - def test_get_reputation_oracle_url_config_url(self): - with patch( - "src.chain.kvstore.Config.localhost.reputation_oracle_url", DEFAULT_MANIFEST_URL - ): - reputation_url = get_reputation_oracle_url(self.w3.eth.chain_id, escrow_address) - assert reputation_url == DEFAULT_MANIFEST_URL - - def test_get_reputation_oracle_url_from_escrow(self): - with ( - patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, - patch("src.chain.kvstore.Config.localhost.reputation_oracle_url", None), - ): - mock_escrow.return_value = self.escrow_data - mock_operator.return_value = MagicMock(webhook_url=DEFAULT_MANIFEST_URL) - reputation_url = get_reputation_oracle_url(self.w3.eth.chain_id, escrow_address) - assert reputation_url == DEFAULT_MANIFEST_URL - - def test_get_reputation_oracle_url_invalid_escrow(self): - with pytest.raises(EscrowClientError, match="Invalid escrow address: invalid address"): - get_reputation_oracle_url(self.w3.eth.chain_id, "invalid address") diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_assignments.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_assignments.py index b4b2679dc1..e631376891 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_assignments.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_assignments.py @@ -1,6 +1,7 @@ import unittest import uuid from datetime import datetime, timedelta +from types import SimpleNamespace from unittest.mock import patch from src.core.types import ( @@ -45,8 +46,8 @@ def test_can_track_expired_assignments(self): id=str(uuid.uuid4()), user_wallet_address=WALLET_ADDRESS1, cvat_job_id=cvat_job.cvat_id, - created_at=datetime.now() - timedelta(hours=2), - expires_at=datetime.now() - timedelta(hours=1), + created_at=datetime.now() - timedelta(hours=4), + expires_at=datetime.now() - timedelta(hours=3), status=AssignmentStatuses.created, ) self.session.add(assignment1) @@ -56,19 +57,28 @@ def test_can_track_expired_assignments(self): user_wallet_address=WALLET_ADDRESS2, cvat_job_id=cvat_job.cvat_id, created_at=datetime.now() - timedelta(hours=1), - expires_at=datetime.now(), + expires_at=datetime.now() - timedelta(minutes=1), status=AssignmentStatuses.created, ) self.session.add(assignment2) self.session.commit() - with patch( - "src.crons.cvat.state_trackers.cvat_api.update_job_assignee" - ) as update_job_assignee: + with ( + patch( + "src.crons.cvat.state_trackers.cvat_api.update_job_assignee" + ) as mock_update_job_assignee, + patch("src.crons.cvat.state_trackers.cvat_api.get_job") as mock_get_job, + ): + mock_get_job.return_value = SimpleNamespace( + id=cvat_job.cvat_id, state="in_progress", assignee=SimpleNamespace(id=2) + ) + track_assignments() + self.session.commit() - update_job_assignee.assert_called_once_with(assignment2.cvat_job_id, assignee_id=None) + mock_get_job.assert_called_once_with(assignment2.cvat_job_id) + mock_update_job_assignee.assert_called_once_with(assignment2.cvat_job_id, assignee_id=None) db_assignments = sorted( self.session.query(Assignment).all(), key=lambda assignment: assignment.user.cvat_id @@ -76,9 +86,72 @@ def test_can_track_expired_assignments(self): assert db_assignments[0].status == AssignmentStatuses.expired assert db_assignments[1].status == AssignmentStatuses.expired - assert ( - self.session.query(Job).filter(Job.id == cvat_job.id).first().status == JobStatuses.new + assert self.session.get(Job, cvat_job.id).status == JobStatuses.new + + def test_can_track_expired_assignments_and_complete_completed(self): + (_, _, cvat_job) = create_project_task_and_job(self.session, ESCROW_ADDRESS, 1) + cvat_job.status = JobStatuses.in_progress + self.session.add(cvat_job) + + user = User( + wallet_address=WALLET_ADDRESS1, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(user) + + user = User( + wallet_address=WALLET_ADDRESS2, + cvat_email="test2@hmt.ai", + cvat_id=2, + ) + self.session.add(user) + + assignment1 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS1, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=4), + expires_at=datetime.now() - timedelta(hours=3), + status=AssignmentStatuses.created, + ) + self.session.add(assignment1) + + assignment2 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS2, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=1), + expires_at=datetime.now() - timedelta(minutes=1), + status=AssignmentStatuses.created, ) + self.session.add(assignment2) + + self.session.commit() + + with ( + patch( + "src.crons.cvat.state_trackers.cvat_api.update_job_assignee" + ) as mock_update_job_assignee, + patch("src.crons.cvat.state_trackers.cvat_api.get_job") as mock_get_job, + ): + mock_get_job.return_value = SimpleNamespace( + id=cvat_job.cvat_id, state="completed", assignee=SimpleNamespace(id=2) + ) + + track_assignments() + self.session.commit() + + mock_get_job.assert_called_once_with(assignment2.cvat_job_id) + mock_update_job_assignee.assert_called_once_with(assignment2.cvat_job_id, assignee_id=None) + + db_assignments = sorted( + self.session.query(Assignment).all(), key=lambda assignment: assignment.user.cvat_id + ) + assert db_assignments[0].status == AssignmentStatuses.expired + assert db_assignments[1].status == AssignmentStatuses.completed + + assert self.session.get(Job, cvat_job.id).status == JobStatuses.completed def test_can_track_canceled_assignments(self): (_, _, cvat_job) = create_project_task_and_job(self.session, ESCROW_ADDRESS, 1) @@ -103,8 +176,8 @@ def test_can_track_canceled_assignments(self): id=str(uuid.uuid4()), user_wallet_address=WALLET_ADDRESS1, cvat_job_id=cvat_job.cvat_id, - created_at=datetime.now() - timedelta(hours=2), - expires_at=datetime.now() - timedelta(hours=1), + created_at=datetime.now() - timedelta(hours=4), + expires_at=datetime.now() - timedelta(hours=3), status=AssignmentStatuses.canceled, ) self.session.add(assignment1) @@ -123,10 +196,11 @@ def test_can_track_canceled_assignments(self): with patch( "src.crons.cvat.state_trackers.cvat_api.update_job_assignee" - ) as update_job_assignee: + ) as mock_update_job_assignee: track_assignments() + self.session.commit() - update_job_assignee.assert_called_once_with(assignment2.cvat_job_id, assignee_id=None) + mock_update_job_assignee.assert_called_once_with(assignment2.cvat_job_id, assignee_id=None) db_assignments = sorted( self.session.query(Assignment).all(), key=lambda assignment: assignment.user.cvat_id @@ -134,6 +208,4 @@ def test_can_track_canceled_assignments(self): assert db_assignments[0].status == AssignmentStatuses.canceled assert db_assignments[1].status == AssignmentStatuses.canceled - assert ( - self.session.query(Job).filter(Job.id == cvat_job.id).first().status == JobStatuses.new - ) + assert self.session.get(Job, cvat_job.id).status == JobStatuses.new diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_webhooks.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_webhooks.py new file mode 100644 index 0000000000..0bcfa5d1b6 --- /dev/null +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_webhooks.py @@ -0,0 +1,315 @@ +import uuid +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest + +from src.core.types import ( + AssignmentStatuses, + CvatWebhookStatuses, + JobStatuses, + ProjectStatuses, +) +from src.crons.cvat.state_trackers import process_incoming_cvat_webhooks +from src.models.cvat import Assignment, CvatWebhook, Job, User +from src.services import cvat as cvat_service + +from tests.utils.constants import ESCROW_ADDRESS, WALLET_ADDRESS1, WALLET_ADDRESS2 +from tests.utils.db_helper import create_project_task_and_job + + +class CvatWebhookHandlingTest: + @pytest.fixture(autouse=True) + def setUp(self, session): + self.session = session + + self.user1 = User( + wallet_address=WALLET_ADDRESS1, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(self.user1) + + self.user2 = User( + wallet_address=WALLET_ADDRESS2, + cvat_email="test2@hmt.ai", + cvat_id=2, + ) + self.session.add(self.user2) + + self.session.commit() + + @pytest.mark.parametrize("is_last_assignment", [True, False]) + def test_can_complete_assignment(self, is_last_assignment: bool): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + cvat_job.status = JobStatuses.in_progress + self.session.add(cvat_job) + + assignment1 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS1, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=4), + expires_at=datetime.now() - timedelta(hours=3), + status=AssignmentStatuses.created, + ) + self.session.add(assignment1) + + assignment2 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS2, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=1), + expires_at=datetime.now(), + status=AssignmentStatuses.created, + ) + self.session.add(assignment2) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={ + "before_update": { + "state": "in_progress", + }, + "job": { + "id": cvat_job.cvat_id, + "assignee": { + "id": (self.user2.cvat_id if is_last_assignment else self.user1.cvat_id) + }, + "state": "completed", + "updated_date": ( + (assignment2.created_at + timedelta(minutes=1)) + if is_last_assignment + else (assignment1.created_at + timedelta(minutes=1)) + ).isoformat() + + "Z", + }, + }, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + with patch( + "src.crons.cvat.state_trackers.cvat_api.update_job_assignee" + ) as mock_update_job_assignee: + process_incoming_cvat_webhooks() + self.session.commit() + + db_assignments = sorted( + self.session.query(Assignment).all(), key=lambda assignment: assignment.user.cvat_id + ) + assert len(db_assignments) == 2 + assert db_assignments[0].status == AssignmentStatuses.created + + if is_last_assignment: + assert db_assignments[1].status == AssignmentStatuses.completed + assert self.session.get(Job, cvat_job.id).status == JobStatuses.completed + mock_update_job_assignee.assert_called_once_with( + assignment2.cvat_job_id, assignee_id=None + ) + else: + assert db_assignments[1].status == AssignmentStatuses.created + assert self.session.get(Job, cvat_job.id).status == JobStatuses.in_progress + mock_update_job_assignee.assert_not_called() + + assert self.session.get(CvatWebhook, webhook_id).status == CvatWebhookStatuses.completed + + @pytest.mark.parametrize("is_last_assignment", [True, False]) + def test_can_expire_assignment(self, is_last_assignment: bool): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + cvat_job.status = JobStatuses.in_progress + self.session.add(cvat_job) + + assignment1 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS1, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=4), + expires_at=datetime.now() - timedelta(hours=3), + status=AssignmentStatuses.created, + ) + self.session.add(assignment1) + + assignment2 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS2, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=1), + expires_at=datetime.now(), + status=AssignmentStatuses.created, + ) + self.session.add(assignment2) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={ + "before_update": { + "state": "in_progress", + }, + "job": { + "id": cvat_job.cvat_id, + "assignee": { + "id": (self.user2.cvat_id if is_last_assignment else self.user1.cvat_id) + }, + "state": "completed", + "updated_date": ( + (assignment2.expires_at + timedelta(minutes=1)) + if is_last_assignment + else (assignment1.expires_at + timedelta(minutes=1)) + ).isoformat() + + "Z", + }, + }, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + with patch( + "src.crons.cvat.state_trackers.cvat_api.update_job_assignee" + ) as mock_update_job_assignee: + process_incoming_cvat_webhooks() + self.session.commit() + + db_assignments = sorted( + self.session.query(Assignment).all(), key=lambda assignment: assignment.user.cvat_id + ) + assert len(db_assignments) == 2 + assert db_assignments[0].status == AssignmentStatuses.created + + if is_last_assignment: + assert db_assignments[1].status == AssignmentStatuses.expired + assert self.session.get(Job, cvat_job.id).status == JobStatuses.new + mock_update_job_assignee.assert_called_once_with( + assignment2.cvat_job_id, assignee_id=None + ) + else: + assert db_assignments[1].status == AssignmentStatuses.created + assert self.session.get(Job, cvat_job.id).status == JobStatuses.in_progress + mock_update_job_assignee.assert_not_called() + + assert self.session.get(CvatWebhook, webhook_id).status == CvatWebhookStatuses.completed + + def test_can_ignore_non_status_update(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + cvat_job.status = JobStatuses.in_progress + self.session.add(cvat_job) + + assignment1 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS1, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=4), + expires_at=datetime.now() - timedelta(hours=3), + status=AssignmentStatuses.created, + ) + self.session.add(assignment1) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + process_incoming_cvat_webhooks() + self.session.commit() + + db_assignments = sorted( + self.session.query(Assignment).all(), key=lambda assignment: assignment.user.cvat_id + ) + assert len(db_assignments) == 1 + assert db_assignments[0].status == AssignmentStatuses.created + assert self.session.get(Job, cvat_job.id).status == JobStatuses.in_progress + assert self.session.get(CvatWebhook, webhook_id).status == CvatWebhookStatuses.completed + + def test_can_ignore_update_for_job_in_non_annotation_status(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + cvat_job.status = JobStatuses.completed + self.session.add(cvat_job) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={ + "before_update": { + "state": "in_progress", + }, + "job": { + "id": cvat_job.cvat_id, + "assignee": {"id": self.user2.cvat_id}, + "state": "completed", + "updated_date": datetime.now().isoformat() + "Z", + }, + }, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + process_incoming_cvat_webhooks() + self.session.commit() + + assert self.session.get(Job, cvat_job.id).status == JobStatuses.completed + assert self.session.get(CvatWebhook, webhook_id).status == CvatWebhookStatuses.completed + + def test_can_ignore_update_for_project_in_non_annotation_status(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + cvat_project.status = ProjectStatuses.deleted + cvat_job.status = JobStatuses.in_progress + self.session.add(cvat_job) + + assignment = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=WALLET_ADDRESS2, + cvat_job_id=cvat_job.cvat_id, + created_at=datetime.now() - timedelta(hours=1), + expires_at=datetime.now() + timedelta(hours=1), + status=AssignmentStatuses.created, + ) + self.session.add(assignment) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={ + "before_update": { + "state": "in_progress", + }, + "job": { + "id": cvat_job.cvat_id, + "assignee": {"id": self.user2.cvat_id}, + "state": "completed", + "updated_date": datetime.now().isoformat() + "Z", + }, + }, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + process_incoming_cvat_webhooks() + self.session.commit() + + assert self.session.get(Job, cvat_job.id).status == JobStatuses.in_progress + assert self.session.get(CvatWebhook, webhook_id).status == CvatWebhookStatuses.completed diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py index e1888c4510..89b166425a 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py @@ -3,13 +3,14 @@ import uuid from unittest.mock import MagicMock, Mock, call, patch -from human_protocol_sdk.constants import ChainId, Status +from human_protocol_sdk.constants import Status from sqlalchemy.sql import select from src.core.storage import compose_data_bucket_prefix, compose_results_bucket_prefix from src.core.types import ( ExchangeOracleEventTypes, JobLauncherEventTypes, + JobStatuses, Networks, OracleWebhookStatuses, OracleWebhookTypes, @@ -23,15 +24,17 @@ ) from src.cvat.api_calls import RequestStatus from src.db import SessionLocal -from src.models.cvat import EscrowCreation, Image, Project +from src.models.cvat import CvatWebhook, EscrowCreation, Image, Project from src.models.webhook import Webhook +from src.services import cvat as cvat_service from src.services.cloud import StorageClient from src.services.webhook import OracleWebhookDirectionTags -from tests.utils.constants import DEFAULT_MANIFEST_URL, JOB_LAUNCHER_ADDRESS +from tests.utils.constants import DEFAULT_MANIFEST_URL, ESCROW_ADDRESS from tests.utils.dataset_helpers import build_gt_dataset +from tests.utils.db_helper import create_project_task_and_job -escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" +escrow_address = ESCROW_ADDRESS chain_id = Networks.localhost.value @@ -288,17 +291,12 @@ def test_process_incoming_job_launcher_webhooks_escrow_created_type_remove_when_ assert db_project is None def test_process_incoming_job_launcher_webhooks_escrow_canceled_type(self): - project_id = str(uuid.uuid4()) - cvat_project = Project( - id=project_id, - cvat_id=1, - cvat_cloudstorage_id=1, - status=ProjectStatuses.completed.value, - job_type=TaskTypes.image_label_binary.value, - escrow_address=escrow_address, - chain_id=Networks.localhost.value, - bucket_url="https://test.storage.googleapis.com/", + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, escrow_address, 1 ) + cvat_project.status = ProjectStatuses.annotation + cvat_task.status = TaskStatuses.annotation + cvat_job.status = JobStatuses.in_progress self.session.add(cvat_project) project_images = [ @@ -311,19 +309,28 @@ def test_process_incoming_job_launcher_webhooks_escrow_canceled_type(self): ] self.session.add_all(project_images) - webhok_id = str(uuid.uuid4()) + webhook_id = str(uuid.uuid4()) webhook = Webhook( - id=webhok_id, + id=webhook_id, signature="signature", escrow_address=escrow_address, chain_id=chain_id, - type=OracleWebhookTypes.job_launcher.value, - status=OracleWebhookStatuses.pending.value, - event_type=JobLauncherEventTypes.escrow_canceled.value, + type=OracleWebhookTypes.job_launcher, + status=OracleWebhookStatuses.pending, + event_type=JobLauncherEventTypes.escrow_canceled, direction=OracleWebhookDirectionTags.incoming, ) self.session.add(webhook) + + cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) self.session.commit() from src.services.cvat import remove_escrow_images as original_remove_escrow_images @@ -344,20 +351,28 @@ def test_process_incoming_job_launcher_webhooks_escrow_canceled_type(self): mock_escrow.return_value = mock_escrow_data process_incoming_job_launcher_webhooks() + self.session.commit() - updated_webhook = ( - self.session.execute(select(Webhook).where(Webhook.id == webhok_id)).scalars().first() - ) + updated_webhook = self.session.get(Webhook, webhook_id) - assert updated_webhook.status == OracleWebhookStatuses.completed.value + assert updated_webhook.status == OracleWebhookStatuses.completed assert updated_webhook.attempts == 1 + db_project = ( self.session.query(Project) .filter_by(escrow_address=escrow_address, chain_id=chain_id) .first() ) + assert db_project.status == ProjectStatuses.canceled - assert db_project.status == ProjectStatuses.canceled.value + db_cvat_webhooks = self.session.execute( + select(CvatWebhook).where( + CvatWebhook.project.has( + (Project.escrow_address == escrow_address) & (Project.chain_id == chain_id) + ) + ) + ).all() + assert not db_cvat_webhooks assert mock_storage_client.remove_files.mock_calls == [ call(prefix=compose_data_bucket_prefix(escrow_address, chain_id)), @@ -614,17 +629,14 @@ def test_process_outgoing_job_launcher_webhooks(self): self.session.add(webhook) self.session.commit() + with ( - patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, + patch( + "src.crons.webhooks.job_launcher.get_job_launcher_url", + return_value=DEFAULT_MANIFEST_URL, + ), patch("httpx.Client.post") as mock_httpx_post, ): - w3 = Mock() - w3.eth.chain_id = ChainId.LOCALHOST.value - mock_escrow_data = Mock() - mock_escrow_data.launcher = JOB_LAUNCHER_ADDRESS - mock_escrow.return_value = mock_escrow_data - mock_operator.return_value = MagicMock(webhook_url=DEFAULT_MANIFEST_URL) mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_httpx_post.return_value = mock_response diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py index 1e11b101f3..96c6f2931c 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py @@ -1,9 +1,8 @@ import unittest import uuid from datetime import timedelta -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -from human_protocol_sdk.constants import ChainId from sqlalchemy.sql import select from src.core.types import ( @@ -30,7 +29,7 @@ from src.services.webhook import OracleWebhookDirectionTags from src.utils.time import utcnow -from tests.utils.constants import DEFAULT_MANIFEST_URL, RECORDING_ORACLE_ADDRESS +from tests.utils.constants import DEFAULT_MANIFEST_URL from tests.utils.db_helper import create_project_task_and_job escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" @@ -437,17 +436,14 @@ def test_process_outgoing_recording_oracle_webhooks(self): self.session.add(webhook) self.session.commit() + with ( - patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, + patch( + "src.crons.webhooks.recording_oracle.get_recording_oracle_url", + return_value=DEFAULT_MANIFEST_URL, + ), patch("httpx.Client.post") as mock_httpx_post, ): - w3 = Mock() - w3.eth.chain_id = ChainId.LOCALHOST.value - mock_escrow_data = Mock() - mock_escrow_data.recording_oracle = RECORDING_ORACLE_ADDRESS - mock_escrow.return_value = mock_escrow_data - mock_operator.return_value = MagicMock(webhook_url=DEFAULT_MANIFEST_URL) mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_httpx_post.return_value = mock_response diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/services/test_cvat.py b/packages/examples/cvat/exchange-oracle/tests/integration/services/test_cvat.py index f5dd8a6ca1..65b07db6ae 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/services/test_cvat.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/services/test_cvat.py @@ -15,10 +15,20 @@ TaskTypes, ) from src.db import SessionLocal -from src.models.cvat import Assignment, DataUpload, Image, Job, Project, Task, User +from src.models.cvat import ( + Assignment, + CvatWebhook, + CvatWebhookStatuses, + DataUpload, + Image, + Job, + Project, + Task, + User, +) from src.utils.time import utcnow -from tests.utils.constants import WALLET_ADDRESS1, WALLET_ADDRESS2 +from tests.utils.constants import ESCROW_ADDRESS, WALLET_ADDRESS1, WALLET_ADDRESS2 from tests.utils.db_helper import ( create_project, create_project_and_task, @@ -1518,6 +1528,56 @@ def test_get_unprocessed_expired_assignments(self): assert assignments[0].id != assignment.id assert assignments[0].user_wallet_address == wallet_address_2 + def test_get_unprocessed_expired_assignments_skips_jobs_with_pending_webhooks(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + + wallet_address_1 = WALLET_ADDRESS1 + user = User( + wallet_address=wallet_address_1, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(user) + + wallet_address_2 = WALLET_ADDRESS2 + user = User( + wallet_address=wallet_address_2, + cvat_email="test2@hmt.ai", + cvat_id=2, + ) + self.session.add(user) + + assignment = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=wallet_address_1, + cvat_job_id=cvat_job.cvat_id, + expires_at=utcnow() + timedelta(days=1), + ) + self.session.add(assignment) + + assignment_2 = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=wallet_address_2, + cvat_job_id=cvat_job.cvat_id, + expires_at=utcnow() - timedelta(days=1), + ) + self.session.add(assignment_2) + self.session.commit() + + cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + + assignments = cvat_service.get_unprocessed_expired_assignments(self.session) + assert not assignments + def test_update_assignment(self): (_, _, cvat_job) = create_project_task_and_job( self.session, "0x86e83d346041E8806e352681f3F14549C0d2BC67", 1 @@ -1730,3 +1790,97 @@ def test_add_project_images(self): images = cvat_service.get_project_images(self.session, 2) assert len(images) == 0 + + def test_can_enqueue_webhook(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + webhook = self.session.get(CvatWebhook, webhook_id) + assert webhook.status == CvatWebhookStatuses.pending + + def test_cannot_enqueue_webhook_for_unknown_project(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + + cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id + 100, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + + with pytest.raises(IntegrityError): + self.session.commit() + + def test_can_get_pending_webhooks(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + webhooks = cvat_service.incoming_webhooks.get_pending_webhooks(self.session) + assert len(webhooks) == 1 + assert webhooks[0].id == webhook_id + + def test_can_complete_webhook(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + cvat_service.incoming_webhooks.handle_webhook_success(self.session, webhook_id) + webhook: CvatWebhook = self.session.get(CvatWebhook, webhook_id) + assert webhook.attempts == 1 + assert webhook.status == CvatWebhookStatuses.completed + + def test_can_fail_webhook(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + + webhook_id = cvat_service.incoming_webhooks.create_webhook( + self.session, + event_type="update:job", + event_data={}, + cvat_project_id=cvat_project.cvat_id, + cvat_task_id=cvat_task.cvat_id, + cvat_job_id=cvat_job.cvat_id, + ) + self.session.commit() + + cvat_service.incoming_webhooks.handle_webhook_fail(self.session, webhook_id) + webhook: CvatWebhook = self.session.get(CvatWebhook, webhook_id) + assert webhook.attempts == 1 + assert webhook.status == CvatWebhookStatuses.pending diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/services/test_exchange.py b/packages/examples/cvat/exchange-oracle/tests/integration/services/test_exchange.py index 311cd8b9b2..e41b5cca89 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/services/test_exchange.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/services/test_exchange.py @@ -15,7 +15,7 @@ from src.schemas import exchange as service_api from src.services.exchange import create_assignment -from tests.utils.constants import ESCROW_ADDRESS, WALLET_ADDRESS1, WALLET_ADDRESS2 +from tests.utils.constants import ESCROW_ADDRESS, HMT_SYMBOL, WALLET_ADDRESS1, WALLET_ADDRESS2 from tests.utils.db_helper import ( create_job, create_project, @@ -41,9 +41,13 @@ def test_serialize_job(self): with ( open("tests/utils/manifest.json") as data, patch("src.endpoints.serializers.get_escrow_manifest") as mock_get_manifest, + patch( + "src.endpoints.serializers.get_escrow_fund_token_symbol" + ) as mock_get_escrow_fund_token_symbol, ): manifest = json.load(data) mock_get_manifest.return_value = manifest + mock_get_escrow_fund_token_symbol.return_value = "HMT" data = serialize_job(cvat_project) assert data.escrow_address == escrow_address @@ -52,7 +56,7 @@ def test_serialize_job(self): assert isinstance(data.reward_amount, str) assert data.reward_amount == manifest["job_bounty"] assert isinstance(data.reward_token, str) - assert data.reward_token == service_api.DEFAULT_TOKEN + assert data.reward_token == HMT_SYMBOL assert data.job_type == cvat_project.job_type assert data.status == service_api.JobStatuses.active assert data.chain_id == cvat_project.chain_id @@ -93,9 +97,15 @@ def test_create_assignment(self): self.session.commit() - with patch("src.services.exchange.cvat_api"): + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest assignment_id = create_assignment( - cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address + cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address, [] ) assignment = self.session.query(Assignment).filter_by(id=assignment_id).first() @@ -142,9 +152,15 @@ def test_create_assignment_many_jobs_1_completed(self): self.session.commit() - with patch("src.services.exchange.cvat_api"): + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest assignment_id = create_assignment( - cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address + cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address, [] ) assignment = self.session.query(Assignment).filter_by(id=assignment_id).first() @@ -162,6 +178,7 @@ def test_create_assignment_invalid_user_address(self): cvat_project_1.escrow_address, Networks(cvat_project_1.chain_id), "invalid_address", + [], ) def test_create_assignment_invalid_project(self): @@ -174,8 +191,82 @@ def test_create_assignment_invalid_project(self): self.session.add(user) self.session.commit() - with pytest.raises(HTTPException, match="Can't find job"): - create_assignment("1", Networks.localhost, user_address) + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest + with pytest.raises(HTTPException, match="Can't find job"): + create_assignment("1", Networks.localhost, user_address, []) + + def test_create_assignment_no_required_qualifications(self): + user_address = WALLET_ADDRESS1 + user = User( + wallet_address=user_address, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(user) + self.session.commit() + + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + ): + manifest = json.load(data) + manifest["qualifications"] = ["random_qualification"] + mock_get_manifest.return_value = manifest + with pytest.raises(Exception, match="User doesn't have required qualifications."): + create_assignment(ESCROW_ADDRESS, Networks.localhost, user_address, []) + + def test_create_assignment_with_required_qualifications(self): + cvat_project, cvat_task, cvat_job = create_project_task_and_job( + self.session, ESCROW_ADDRESS, 1 + ) + initial_job_updated_at = cvat_job.updated_at + initial_task_updated_at = cvat_task.updated_at + initial_project_updated_at = cvat_project.updated_at + + user_address = WALLET_ADDRESS1 + user = User( + wallet_address=user_address, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(user) + + self.session.commit() + + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + manifest["qualifications"] = ["test", "test2"] + mock_get_manifest.return_value = manifest + assignment_id = create_assignment( + cvat_project.escrow_address, + Networks(cvat_project.chain_id), + user_address, + ["test", "test2", "test3"], + ) + + assignment = self.session.query(Assignment).filter_by(id=assignment_id).first() + + assert assignment.cvat_job_id == cvat_job.cvat_id + assert assignment.user_wallet_address == user_address + assert assignment.status == AssignmentStatuses.created + + self.session.refresh(cvat_job) + assert cvat_job.updated_at != initial_job_updated_at + + self.session.refresh(cvat_task) + assert cvat_task.updated_at != initial_task_updated_at + + self.session.refresh(cvat_project) + assert cvat_project.updated_at != initial_project_updated_at def test_create_assignment_unfinished_assignment(self): _, _, cvat_job = create_project_task_and_job(self.session, ESCROW_ADDRESS, 1) @@ -201,10 +292,14 @@ def test_create_assignment_unfinished_assignment(self): self.session.commit() with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, patch("src.services.exchange.cvat_api"), - pytest.raises(Exception, match="unfinished assignment"), ): - create_assignment(ESCROW_ADDRESS, Networks.localhost, user_address) + manifest = json.load(data) + mock_get_manifest.return_value = manifest + with pytest.raises(Exception, match="unfinished assignment"): + create_assignment(ESCROW_ADDRESS, Networks.localhost, user_address, []) def test_create_assignment_has_expired_assignment_and_available_jobs(self): escrow_address = ESCROW_ADDRESS @@ -234,8 +329,16 @@ def test_create_assignment_has_expired_assignment_and_available_jobs(self): self.session.commit() - with patch("src.services.exchange.cvat_api"): - new_assignment_id = create_assignment(escrow_address, Networks.localhost, user_address) + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest + new_assignment_id = create_assignment( + escrow_address, Networks.localhost, user_address, [] + ) new_assignment = self.session.query(Assignment).filter_by(id=new_assignment_id).first() assert new_assignment.cvat_job_id == cvat_job2.cvat_id # job1 was attempted already @@ -276,9 +379,15 @@ def test_create_assignment_no_available_jobs_completed_assignment(self): self.session.commit() - with patch("src.services.exchange.cvat_api"): + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest assignment_id = create_assignment( - cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address2 + cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address2, [] ) assert assignment_id == None @@ -315,9 +424,15 @@ def test_create_assignment_no_available_jobs_active_foreign_assignment(self): self.session.commit() - with patch("src.services.exchange.cvat_api"): + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest assignment_id = create_assignment( - cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address2 + cvat_project.escrow_address, Networks(cvat_project.chain_id), user_address2, [] ) assert assignment_id == None @@ -347,11 +462,18 @@ def test_create_assignment_wont_reassign_job_to_previous_user(self): self.session.commit() - with patch("src.services.exchange.cvat_api"): + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest assignment_id = create_assignment( cvat_project_1.escrow_address, Networks(cvat_project_1.chain_id), user.wallet_address, + [], ) assert assignment_id is None @@ -387,11 +509,18 @@ def test_create_assignment_can_assign_job_to_new_user(self): self.session.commit() - with patch("src.services.exchange.cvat_api"): + with ( + open("tests/utils/manifest.json") as data, + patch("src.services.exchange.get_escrow_manifest") as mock_get_manifest, + patch("src.services.exchange.cvat_api"), + ): + manifest = json.load(data) + mock_get_manifest.return_value = manifest assignment_id = create_assignment( cvat_project_1.escrow_address, Networks(cvat_project_1.chain_id), new_user.wallet_address, + [], ) assignment = self.session.get(Assignment, assignment_id) diff --git a/packages/examples/cvat/exchange-oracle/tests/utils/constants.py b/packages/examples/cvat/exchange-oracle/tests/utils/constants.py index a1d535a814..fb27694525 100644 --- a/packages/examples/cvat/exchange-oracle/tests/utils/constants.py +++ b/packages/examples/cvat/exchange-oracle/tests/utils/constants.py @@ -20,6 +20,8 @@ TOKEN_ADDRESS = "0x976EA74026E726554dB657fA54763abd0C3a0aa9" FACTORY_ADDRESS = "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955" +HMT_SYMBOL = "HMT" + WALLET_ADDRESS1 = "0x86e83d346041E8806e352681f3F14549C0d2BC69" WALLET_ADDRESS2 = "0x86e83d346041E8806e352681f3F14549C0d2BC70" diff --git a/packages/examples/cvat/exchange-oracle/tests/utils/manifest.json b/packages/examples/cvat/exchange-oracle/tests/utils/manifest.json index 2577f20c38..950b15cd3f 100644 --- a/packages/examples/cvat/exchange-oracle/tests/utils/manifest.json +++ b/packages/examples/cvat/exchange-oracle/tests/utils/manifest.json @@ -15,5 +15,6 @@ "val_size": 2, "gt_url": "https://test.storage.googleapis.com" }, - "job_bounty": "5.001123929619726" + "job_bounty": "5.001123929619726", + "qualifications": [] } diff --git a/packages/examples/cvat/recording-oracle/poetry.lock b/packages/examples/cvat/recording-oracle/poetry.lock index 42e4ed3b57..a6883c0a02 100644 --- a/packages/examples/cvat/recording-oracle/poetry.lock +++ b/packages/examples/cvat/recording-oracle/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -2031,13 +2031,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "human-protocol-sdk" -version = "4.1.4" +version = "4.3.0" description = "A python library to launch escrow contracts to the HUMAN network." optional = false python-versions = "*" files = [ - {file = "human_protocol_sdk-4.1.4-py3-none-any.whl", hash = "sha256:c0dbaaf332a8e130d7378f36876a719bef595febffcc012a76af63ce9b2ed1a1"}, - {file = "human_protocol_sdk-4.1.4.tar.gz", hash = "sha256:9fb7b9886a7585e0ca5a8a4c390cc0f657b0e1ccdafa5504472deb1431e4438c"}, + {file = "human_protocol_sdk-4.3.0-py3-none-any.whl", hash = "sha256:498276ba47157615df7e914374fcb51f788e1892e2c169432deb2055108da525"}, + {file = "human_protocol_sdk-4.3.0.tar.gz", hash = "sha256:a1d172899c79c67d9b4266854252e02ae17f157bf1744710843e376c98c95738"}, ] [package.dependencies] @@ -3225,6 +3225,8 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, + {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, + {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -3617,6 +3619,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3856,30 +3859,50 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, @@ -4732,4 +4755,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10, <3.13" -content-hash = "6c6fff2e9607b483bba4110eb75ac217db72021a2639db3b7a73e40829705411" +content-hash = "82157c082fb82718a5e092b999f14d3c18ca27a2acfb198101e5dcae6c94e42a" diff --git a/packages/examples/cvat/recording-oracle/pyproject.toml b/packages/examples/cvat/recording-oracle/pyproject.toml index 1a2960eb4b..e332a910e5 100644 --- a/packages/examples/cvat/recording-oracle/pyproject.toml +++ b/packages/examples/cvat/recording-oracle/pyproject.toml @@ -26,7 +26,7 @@ hexbytes = ">=1.2.0" # required for to_0x_hex() function starlette = ">=0.40.0" # avoid the vulnerability with multipart/form-data cvat-sdk = "2.37.0" cryptography = "<44.0.0" # human-protocol-sdk -> pgpy dep requires cryptography < 45 -human-protocol-sdk = "^4.1.4" +human-protocol-sdk = "^4.3.0" [tool.poetry.group.dev.dependencies] hypothesis = "^6.82.6" diff --git a/packages/examples/cvat/recording-oracle/src/chain/escrow.py b/packages/examples/cvat/recording-oracle/src/chain/escrow.py index bc6e89da60..bd21e2ff18 100644 --- a/packages/examples/cvat/recording-oracle/src/chain/escrow.py +++ b/packages/examples/cvat/recording-oracle/src/chain/escrow.py @@ -46,7 +46,7 @@ def validate_escrow( def get_escrow_manifest(chain_id: int, escrow_address: str) -> dict: escrow = get_escrow(chain_id, escrow_address) - manifest_content = StorageUtils.download_file_from_url(escrow.manifest_url).decode("utf-8") + manifest_content = StorageUtils.download_file_from_url(escrow.manifest).decode("utf-8") if EncryptionUtils.is_encrypted(manifest_content): encryption = Encryption( diff --git a/packages/examples/cvat/recording-oracle/src/chain/kvstore.py b/packages/examples/cvat/recording-oracle/src/chain/kvstore.py index 602ba2f124..60a8baa387 100644 --- a/packages/examples/cvat/recording-oracle/src/chain/kvstore.py +++ b/packages/examples/cvat/recording-oracle/src/chain/kvstore.py @@ -1,6 +1,5 @@ -from human_protocol_sdk.constants import ChainId, KVStoreKeys +from human_protocol_sdk.constants import KVStoreKeys from human_protocol_sdk.kvstore import KVStoreClient, KVStoreClientError, KVStoreUtils -from human_protocol_sdk.operator import OperatorUtils from src.chain.escrow import get_escrow from src.chain.web3 import get_web3 @@ -13,7 +12,10 @@ def get_exchange_oracle_url(chain_id: int, escrow_address: str) -> str: escrow = get_escrow(chain_id, escrow_address) - return OperatorUtils.get_operator(ChainId(chain_id), escrow.exchange_oracle).webhook_url + # Subgraph can return invalid values, use KVStore itself + w3 = get_web3(chain_id) + kvstore_client = KVStoreClient(w3) + return kvstore_client.get(escrow.exchange_oracle, "webhook_url") def get_reputation_oracle_url(chain_id: int, escrow_address: str) -> str: @@ -22,7 +24,10 @@ def get_reputation_oracle_url(chain_id: int, escrow_address: str) -> str: escrow = get_escrow(chain_id, escrow_address) - return OperatorUtils.get_operator(ChainId(chain_id), escrow.reputation_oracle).webhook_url + # Subgraph can return invalid values, use KVStore itself + w3 = get_web3(chain_id) + kvstore_client = KVStoreClient(w3) + return kvstore_client.get(escrow.reputation_oracle, "webhook_url") def register_in_kvstore() -> None: diff --git a/packages/examples/cvat/recording-oracle/src/core/types.py b/packages/examples/cvat/recording-oracle/src/core/types.py index 655f352380..a3d1695f8f 100644 --- a/packages/examples/cvat/recording-oracle/src/core/types.py +++ b/packages/examples/cvat/recording-oracle/src/core/types.py @@ -20,13 +20,13 @@ class TaskTypes(str, Enum, metaclass=BetterEnumMeta): image_skeletons_from_boxes = "image_skeletons_from_boxes" -class OracleWebhookTypes(str, Enum): +class OracleWebhookTypes(str, Enum, metaclass=BetterEnumMeta): exchange_oracle = "exchange_oracle" recording_oracle = "recording_oracle" reputation_oracle = "reputation_oracle" -class OracleWebhookStatuses(str, Enum): +class OracleWebhookStatuses(str, Enum, metaclass=BetterEnumMeta): pending = "pending" completed = "completed" failed = "failed" diff --git a/packages/examples/cvat/recording-oracle/tests/integration/chain/test_escrow.py b/packages/examples/cvat/recording-oracle/tests/integration/chain/test_escrow.py index fa21b44f59..a06702b6ac 100644 --- a/packages/examples/cvat/recording-oracle/tests/integration/chain/test_escrow.py +++ b/packages/examples/cvat/recording-oracle/tests/integration/chain/test_escrow.py @@ -59,7 +59,7 @@ def escrow(self, status: str = "Pending", balance: float = amount): mock_escrow.status = status mock_escrow.balance = balance mock_escrow.reputation_oracle = REPUTATION_ORACLE_ADDRESS - mock_escrow.manifest_url = "http://s3.amazonaws.com" + mock_escrow.manifest = "http://s3.amazonaws.com" return mock_escrow def test_validate_escrow(self): diff --git a/packages/examples/cvat/recording-oracle/tests/integration/chain/test_kvstore.py b/packages/examples/cvat/recording-oracle/tests/integration/chain/test_kvstore.py index 4c50cb0005..a8ffa538ff 100644 --- a/packages/examples/cvat/recording-oracle/tests/integration/chain/test_kvstore.py +++ b/packages/examples/cvat/recording-oracle/tests/integration/chain/test_kvstore.py @@ -1,29 +1,33 @@ -import unittest -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest from human_protocol_sdk.constants import ChainId, Status from human_protocol_sdk.escrow import EscrowClientError, EscrowData from human_protocol_sdk.kvstore import KVStoreClientError, KVStoreUtils -from src.chain.kvstore import get_reputation_oracle_url, register_in_kvstore +from src.chain.kvstore import ( + get_exchange_oracle_url, + get_reputation_oracle_url, + register_in_kvstore, +) from src.core.config import LocalhostConfig from tests.utils.constants import ( DEFAULT_MANIFEST_URL, ESCROW_ADDRESS, + EXCHANGE_ORACLE_ADDRESS, FACTORY_ADDRESS, JOB_LAUNCHER_ADDRESS, REPUTATION_ORACLE_ADDRESS, - REPUTATION_ORACLE_WEBHOOK_URL, TOKEN_ADDRESS, ) escrow_address = ESCROW_ADDRESS -class ServiceIntegrationTest(unittest.TestCase): - def setUp(self): +class ServiceIntegrationTest: + @pytest.fixture(autouse=True) + def setup(self): self.w3 = Mock() self.w3.eth.chain_id = ChainId.LOCALHOST.value self.escrow_data = EscrowData( @@ -39,33 +43,27 @@ def setUp(self): token=TOKEN_ADDRESS, total_funded_amount=1000, created_at="", - manifest_url=DEFAULT_MANIFEST_URL, + manifest=DEFAULT_MANIFEST_URL, + exchange_oracle=EXCHANGE_ORACLE_ADDRESS, reputation_oracle=REPUTATION_ORACLE_ADDRESS, ) - def test_get_reputation_oracle_url(self): + @pytest.mark.parametrize("get_url", [get_reputation_oracle_url, get_exchange_oracle_url]) + def test_get_oracle_url(self, get_url): with ( patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, + patch("src.chain.kvstore.get_web3", return_value=self.w3), + patch("src.chain.kvstore.KVStoreClient.get") as mock_kvstore_get, ): mock_escrow.return_value = self.escrow_data - mock_operator.return_value = MagicMock(webhook_url=REPUTATION_ORACLE_WEBHOOK_URL) - reputation_url = get_reputation_oracle_url(self.w3.eth.chain_id, escrow_address) - assert reputation_url == REPUTATION_ORACLE_WEBHOOK_URL + mock_kvstore_get.return_value = DEFAULT_MANIFEST_URL + actual_url = get_url(self.w3.eth.chain_id, escrow_address) + assert actual_url == DEFAULT_MANIFEST_URL - def test_get_reputation_oracle_url_invalid_escrow(self): + @pytest.mark.parametrize("get_url", [get_reputation_oracle_url, get_exchange_oracle_url]) + def test_get_oracle_url_invalid_escrow(self, get_url): with pytest.raises(EscrowClientError, match="Invalid escrow address: invalid_address"): - get_reputation_oracle_url(self.w3.eth.chain_id, "invalid_address") - - def test_get_reputation_oracle_url_invalid_address(self): - with ( - patch("src.chain.kvstore.get_escrow") as mock_escrow, - patch("src.chain.kvstore.OperatorUtils.get_operator") as mock_operator, - ): - mock_escrow.return_value = self.escrow_data - mock_operator.return_value = MagicMock(webhook_url="") - recording_url = get_reputation_oracle_url(self.w3.eth.chain_id, escrow_address) - assert recording_url == "" + get_url(self.w3.eth.chain_id, "invalid_address") def test_store_public_key(self): PGP_PUBLIC_KEY_URL_1 = "http://pgp-public-key-url-1" diff --git a/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py b/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py index eeed92e681..50e2d18705 100644 --- a/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py +++ b/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py @@ -8,6 +8,7 @@ from web3.middleware import SignAndSendRawMiddlewareBuilder from web3.providers.rpc import HTTPProvider +from src.core.config import Config from src.core.storage import compose_data_bucket_prefix, compose_results_bucket_prefix from src.core.types import ( ExchangeOracleEventTypes, @@ -32,7 +33,7 @@ class ServiceIntegrationTest(unittest.TestCase): def setUp(self): self.session = SessionLocal() - self.w3 = Web3(HTTPProvider()) + self.w3 = Web3(HTTPProvider(Config.localhost.rpc_api)) self.gas_payer = self.w3.eth.account.from_key(DEFAULT_GAS_PAYER_PRIV) self.w3.middleware_onion.inject( @@ -193,12 +194,10 @@ def test_process_recording_oracle_webhooks_invalid_escrow_balance(self): assert updated_webhook.status == OracleWebhookStatuses.pending.value assert updated_webhook.attempts == 1 - @patch("src.chain.escrow.EscrowClient.get_manifest_url") - def test_process_job_launcher_webhooks_invalid_manifest_url(self, mock_manifest_url): - mock_manifest_url.return_value = "invalid_url" + def test_process_job_launcher_webhooks_invalid_manifest_url(self): escrow_address = create_escrow(self.w3) fund_escrow(self.w3, escrow_address) - setup_escrow(self.w3, escrow_address) + setup_escrow(self.w3, escrow_address, manifest="http://localhost/invalid/url") webhook = self.make_webhook(escrow_address) diff --git a/packages/examples/cvat/recording-oracle/tests/utils/setup_escrow.py b/packages/examples/cvat/recording-oracle/tests/utils/setup_escrow.py index 9910375dc1..f289738487 100644 --- a/packages/examples/cvat/recording-oracle/tests/utils/setup_escrow.py +++ b/packages/examples/cvat/recording-oracle/tests/utils/setup_escrow.py @@ -32,7 +32,7 @@ def create_escrow(web3: Web3): ) -def setup_escrow(web3: Web3, escrow_address: str): +def setup_escrow(web3: Web3, escrow_address: str, *, manifest: str = DEFAULT_MANIFEST_URL): escrow_client = EscrowClient(web3) escrow_client.setup( escrow_address=escrow_address, @@ -43,7 +43,7 @@ def setup_escrow(web3: Web3, escrow_address: str): recording_oracle_fee=RECORDING_ORACLE_FEE, reputation_oracle_address=REPUTATION_ORACLE_ADDRESS, reputation_oracle_fee=REPUTATION_ORACLE_FEE, - manifest_url=DEFAULT_MANIFEST_URL, + manifest=manifest, hash=DEFAULT_HASH, ), )