From a5ea1289761af082e490e1e227d0a8ab889da703 Mon Sep 17 00:00:00 2001 From: flopez7 Date: Tue, 5 May 2026 18:56:27 +0200 Subject: [PATCH 1/6] fix oracle fee calculations and related functions to Escrow --- .../src/modules/job/job.service.spec.ts | 13 +- .../src/modules/job/job.service.ts | 13 +- .../server/src/common/utils/tokens.ts | 16 + .../server/src/common/validators/tokens.ts | 2 +- .../server/src/modules/job/job.repository.ts | 2 +- .../src/modules/job/job.service.spec.ts | 72 +++- .../server/src/modules/job/job.service.ts | 23 +- packages/core/contracts/Escrow.sol | 175 +++++---- packages/core/test/Escrow.ts | 335 ++++++++++++------ .../escrow/escrow_client.py | 38 ++ .../escrow/test_escrow_client.py | 54 +++ .../src/escrow/escrow_client.ts | 50 +++ .../human-protocol-sdk/test/escrow.test.ts | 69 ++++ .../src/mapping/EscrowTemplate.ts | 57 +++ .../subgraph/human-protocol/template.yaml | 2 + .../tests/escrow/escrow.test.ts | 123 +++++++ .../human-protocol/tests/escrow/fixtures.ts | 36 ++ 17 files changed, 856 insertions(+), 224 deletions(-) diff --git a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts index e9094e4d21..2e88a58258 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts @@ -9,6 +9,7 @@ import { import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; +import { ethers } from 'ethers'; import { of, throwError } from 'rxjs'; import { MOCK_ADDRESS, @@ -316,7 +317,7 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://example.com/results'), storeResults: jest.fn().mockResolvedValue(true), - getTokenAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), + getRemainingFunds: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); @@ -370,7 +371,7 @@ describe('JobService', () => { getManifest: jest.fn().mockResolvedValue('http://example.com/manifest'), getIntermediateResultsUrl: jest.fn().mockResolvedValue(''), storeResults: jest.fn().mockResolvedValue(true), - getTokenAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), + getRemainingFunds: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); @@ -426,7 +427,7 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getTokenAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), + getRemainingFunds: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); @@ -504,7 +505,7 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getTokenAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), + getRemainingFunds: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); @@ -594,7 +595,7 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getTokenAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), + getRemainingFunds: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); KVStoreUtils.get = jest @@ -676,7 +677,7 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getTokenAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), + getRemainingFunds: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); KVStoreUtils.get = jest diff --git a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts index cccaa60244..5cc201d413 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts @@ -25,7 +25,6 @@ import { SolutionEventData, WebhookDto, } from '../webhook/webhook.dto'; -import { HMToken__factory } from '@human-protocol/core/typechain-types'; @Injectable() export class JobService { @@ -121,7 +120,7 @@ export class JobService { } const manifestUrl = await escrowClient.getManifest(webhook.escrowAddress); - const { submissionsRequired, requestType, fundAmount }: IManifest = + const { submissionsRequired, requestType }: IManifest = await this.storageService.download(manifestUrl); if (!submissionsRequired || !requestType) { @@ -186,16 +185,10 @@ export class JobService { s.solution === lastExchangeSolution.solution, ); - const tokenAddress = await escrowClient.getTokenAddress( + const remainingFunds = await escrowClient.getRemainingFunds( webhook.escrowAddress, ); - const tokenContract = HMToken__factory.connect( - tokenAddress, - this.web3Service.getSigner(webhook.chainId), - ); - const decimals = await tokenContract.decimals(); - const fundAmountInWei = ethers.parseUnits(fundAmount.toString(), decimals); - const amountToReserve = fundAmountInWei / BigInt(submissionsRequired); + const amountToReserve = remainingFunds / BigInt(submissionsRequired); await escrowClient.storeResults( webhook.escrowAddress, diff --git a/packages/apps/job-launcher/server/src/common/utils/tokens.ts b/packages/apps/job-launcher/server/src/common/utils/tokens.ts index 3b7b02fc1f..7e5ff5264c 100644 --- a/packages/apps/job-launcher/server/src/common/utils/tokens.ts +++ b/packages/apps/job-launcher/server/src/common/utils/tokens.ts @@ -1,4 +1,5 @@ import { ChainId } from '@human-protocol/sdk'; +import { ethers } from 'ethers'; import { TOKEN_ADDRESSES } from '../constants/tokens'; import { EscrowFundToken } from '../enums/job'; @@ -16,3 +17,18 @@ export function getTokenDecimals( defaultDecimals, ); } + +export function calculateNetFundAmount( + fundAmount: number, + decimals: number, + oracleFeePercentages: bigint[], +): number { + const fundAmountInWei = ethers.parseUnits(fundAmount.toString(), decimals); + const oracleFees = oracleFeePercentages.reduce( + (totalFees, feePercentage) => + totalFees + (fundAmountInWei * feePercentage) / 100n, + 0n, + ); + + return Number(ethers.formatUnits(fundAmountInWei - oracleFees, decimals)); +} diff --git a/packages/apps/job-launcher/server/src/common/validators/tokens.ts b/packages/apps/job-launcher/server/src/common/validators/tokens.ts index 81e38a3fac..d957dfbda5 100644 --- a/packages/apps/job-launcher/server/src/common/validators/tokens.ts +++ b/packages/apps/job-launcher/server/src/common/validators/tokens.ts @@ -5,7 +5,7 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import { JobDto } from 'src/modules/job/job.dto'; +import { JobDto } from '../../modules/job/job.dto'; import { TOKEN_ADDRESSES } from '../constants/tokens'; import { ChainId } from '@human-protocol/sdk'; diff --git a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts index 31a644e310..0dadd98f28 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts @@ -18,7 +18,7 @@ import { JobCountDto, } from '../statistic/statistic.dto'; import { convertToDatabaseSortDirection } from '../../database/database.utils'; -import { PaymentSource } from 'src/common/enums/payment'; +import { PaymentSource } from '../../common/enums/payment'; @Injectable() export class JobRepository extends BaseRepository { diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index 27178d5ef5..7201841635 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -38,7 +38,10 @@ import { ValidationError, } from '../../common/errors'; import { div, max, mul } from '../../common/utils/decimal'; -import { getTokenDecimals } from '../../common/utils/tokens'; +import { + calculateNetFundAmount, + getTokenDecimals, +} from '../../common/utils/tokens'; import { createMockCvatManifest, createMockFortuneManifest, @@ -176,7 +179,16 @@ describe('JobService', () => { mockRateService.getRate .mockResolvedValueOnce(tokenToUsdRate) .mockResolvedValueOnce(usdToTokenRate); - mockedKVStoreUtils.get.mockResolvedValueOnce('1'); + const oracleFeePercentages = [ + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + ]; + mockedKVStoreUtils.get + .mockResolvedValueOnce('1') + .mockResolvedValueOnce(oracleFeePercentages[0].toString()) + .mockResolvedValueOnce(oracleFeePercentages[1].toString()) + .mockResolvedValueOnce(oracleFeePercentages[2].toString()); const result = await jobService.createJob( userMock, @@ -206,7 +218,11 @@ describe('JobService', () => { expect(mockManifestService.createManifest).toHaveBeenCalledWith( fortuneJobDto, FortuneJobType.FORTUNE, - fortuneJobDto.paymentAmount, + calculateNetFundAmount( + fortuneJobDto.paymentAmount, + fundTokenDecimals, + oracleFeePercentages, + ), fundTokenDecimals, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( @@ -276,7 +292,16 @@ describe('JobService', () => { mockRateService.getRate .mockResolvedValueOnce(tokenToUsdRate) .mockResolvedValueOnce(usdToTokenRate); - mockedKVStoreUtils.get.mockResolvedValueOnce('1'); + const oracleFeePercentages = [ + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + ]; + mockedKVStoreUtils.get + .mockResolvedValueOnce('1') + .mockResolvedValueOnce(oracleFeePercentages[0].toString()) + .mockResolvedValueOnce(oracleFeePercentages[1].toString()) + .mockResolvedValueOnce(oracleFeePercentages[2].toString()); const result = await jobService.createJob( userMock, @@ -307,7 +332,11 @@ describe('JobService', () => { expect(mockManifestService.createManifest).toHaveBeenCalledWith( fortuneJobDto, FortuneJobType.FORTUNE, - Number(fortuneJobDto.paymentAmount.toFixed(6)), + calculateNetFundAmount( + Number(fortuneJobDto.paymentAmount.toFixed(6)), + fundTokenDecimals, + oracleFeePercentages, + ), fundTokenDecimals, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( @@ -395,7 +424,16 @@ describe('JobService', () => { exchangeOracle: mockOracles.exchangeOracle, reputationOracle: mockOracles.reputationOracle, }); - mockedKVStoreUtils.get.mockResolvedValueOnce('1'); + const oracleFeePercentages = [ + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + ]; + mockedKVStoreUtils.get + .mockResolvedValueOnce('1') + .mockResolvedValueOnce(oracleFeePercentages[0].toString()) + .mockResolvedValueOnce(oracleFeePercentages[1].toString()) + .mockResolvedValueOnce(oracleFeePercentages[2].toString()); const result = await jobService.createJob( userMock, @@ -424,7 +462,11 @@ describe('JobService', () => { expect(mockManifestService.createManifest).toHaveBeenCalledWith( fortuneJobDto, FortuneJobType.FORTUNE, - fortuneJobDto.paymentAmount, + calculateNetFundAmount( + fortuneJobDto.paymentAmount, + fundTokenDecimals, + oracleFeePercentages, + ), fundTokenDecimals, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( @@ -510,6 +552,16 @@ describe('JobService', () => { mockRateService.getRate .mockResolvedValueOnce(tokenToUsdRate) .mockResolvedValueOnce(usdToTokenRate); + const oracleFeePercentages = [ + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + BigInt(faker.number.int({ min: 1, max: 25 })), + ]; + mockedKVStoreUtils.get + .mockResolvedValueOnce('1') + .mockResolvedValueOnce(oracleFeePercentages[0].toString()) + .mockResolvedValueOnce(oracleFeePercentages[1].toString()) + .mockResolvedValueOnce(oracleFeePercentages[2].toString()); await jobService.createJob(userMock, cvatJobDto.type, cvatJobDto); @@ -527,7 +579,11 @@ describe('JobService', () => { expect(mockManifestService.createManifest).toHaveBeenCalledWith( cvatJobDto, cvatJobDto.type, - cvatJobDto.paymentAmount, + calculateNetFundAmount( + cvatJobDto.paymentAmount, + fundTokenDecimals, + oracleFeePercentages, + ), fundTokenDecimals, ); expect(mockManifestService.uploadManifest).toHaveBeenCalledWith( diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 271743182f..1e3efcde91 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -49,7 +49,10 @@ import { import { PageDto } from '../../common/pagination/pagination.dto'; import { parseUrl } from '../../common/utils'; import { add, div, max, mul } from '../../common/utils/decimal'; -import { getTokenDecimals } from '../../common/utils/tokens'; +import { + calculateNetFundAmount, + getTokenDecimals, +} from '../../common/utils/tokens'; import logger from '../../logger'; import { CronJobRepository } from '../cron-job/cron-job.repository'; import { @@ -83,8 +86,7 @@ import { JobRepository } from './job.repository'; @Injectable() export class JobService { private readonly logger = logger.child({ context: JobService.name }); - public readonly bucket: string; - private cronJobRepository: CronJobRepository; + private cronJobRepository!: CronJobRepository; constructor( @Inject(Web3Service) @@ -218,6 +220,17 @@ export class JobService { ); } + const oracleFeePercentages = await Promise.all([ + this.getOracleFee(reputationOracle, chainId), + this.getOracleFee(recordingOracle, chainId), + this.getOracleFee(exchangeOracle, chainId), + ]); + const netFundAmount = calculateNetFundAmount( + fundTokenAmount, + fundTokenDecimals, + oracleFeePercentages, + ); + if (dto.qualifications) { const validQualifications = await this.qualificationService.getQualifications(chainId); @@ -253,7 +266,7 @@ export class JobService { const manifestOrigin = await this.manifestService.createManifest( dto, requestType, - fundTokenAmount, + netFundAmount, fundTokenDecimals, ); @@ -454,7 +467,7 @@ export class JobService { }); return new PageDto(data.page!, data.pageSize!, itemCount, jobs); - } catch (error) { + } catch (error: any) { throw new ServerError(error.message, error.stack); } } diff --git a/packages/core/contracts/Escrow.sol b/packages/core/contracts/Escrow.sol index d4ab130a6f..6feab48841 100644 --- a/packages/core/contracts/Escrow.sol +++ b/packages/core/contracts/Escrow.sol @@ -15,12 +15,6 @@ interface IKVStore { ) external view returns (string memory); } -struct Fees { - uint256 reputation; - uint256 recording; - uint256 exchange; -} - /** * @title Escrow Contract * @dev This contract manages the lifecycle of an escrow, including funding, @@ -80,6 +74,7 @@ contract Escrow is IEscrow, ReentrancyGuard { event Withdraw(address token, uint256 amount); event CancellationRequested(); event CancellationRefund(uint256 amount); + event OracleFeeTransfer(address[] oracles, uint256[] amounts); EscrowStatuses public override status; @@ -106,6 +101,7 @@ contract Escrow is IEscrow, ReentrancyGuard { uint256 public duration; mapping(bytes32 => bool) private payouts; + uint256 public fundAmount; uint256 public remainingFunds; uint256 public reservedFunds; @@ -191,8 +187,21 @@ contract Escrow is IEscrow, ReentrancyGuard { manifestHash = _manifestHash; status = EscrowStatuses.Pending; - remainingFunds = getBalance(); - require(remainingFunds > 0, 'Zero balance'); + uint256 balance = getBalance(); + require(balance > 0, 'Zero balance'); + + fundAmount = balance; + uint256 reputationOracleFee = (balance * + _reputationOracleFeePercentage) / 100; + uint256 recordingOracleFee = (balance * _recordingOracleFeePercentage) / + 100; + uint256 exchangeOracleFee = (balance * _exchangeOracleFeePercentage) / + 100; + remainingFunds = + balance - + reputationOracleFee - + recordingOracleFee - + exchangeOracleFee; emit PendingV3( _manifest, @@ -204,7 +213,7 @@ contract Escrow is IEscrow, ReentrancyGuard { _recordingOracleFeePercentage, _exchangeOracleFeePercentage ); - emit Fund(remainingFunds); + emit Fund(balance); } function _getOracleFee(address _oracle) private view returns (uint8) { @@ -269,8 +278,12 @@ contract Escrow is IEscrow, ReentrancyGuard { uint256 amount; if (_token == token) { uint256 balance = getBalance(); - require(balance > remainingFunds, 'No funds'); - amount = balance - remainingFunds; + uint256 lockedFunds = remainingFunds + + ((fundAmount * reputationOracleFeePercentage) / 100) + + ((fundAmount * recordingOracleFeePercentage) / 100) + + ((fundAmount * exchangeOracleFeePercentage) / 100); + require(balance > lockedFunds, 'No funds'); + amount = balance - lockedFunds; } else { amount = getTokenBalance(_token); } @@ -310,16 +323,53 @@ contract Escrow is IEscrow, ReentrancyGuard { */ function _finalize() private { EscrowStatuses _status = status; - uint256 _remaining = remainingFunds; + uint256 _remainingFunds = remainingFunds; + + uint256 _reputationOracleFee = (fundAmount * + reputationOracleFeePercentage) / 100; + uint256 _recordingOracleFee = (fundAmount * + recordingOracleFeePercentage) / 100; + uint256 _exchangeOracleFee = (fundAmount * + exchangeOracleFeePercentage) / 100; + + IERC20 tokenContract = IERC20(token); - if (_remaining > 0) { - IERC20 tokenContract = IERC20(token); - tokenContract.safeTransfer(launcher, _remaining); + fundAmount = 0; + remainingFunds = 0; + reservedFunds = 0; + + if (_reputationOracleFee > 0) { + tokenContract.safeTransfer(reputationOracle, _reputationOracleFee); + } + if (_recordingOracleFee > 0) { + tokenContract.safeTransfer(recordingOracle, _recordingOracleFee); + } + if (_exchangeOracleFee > 0) { + tokenContract.safeTransfer(exchangeOracle, _exchangeOracleFee); + } + if ( + _reputationOracleFee > 0 || + _recordingOracleFee > 0 || + _exchangeOracleFee > 0 + ) { + address[] memory oracles = new address[](3); + uint256[] memory amounts = new uint256[](3); + + oracles[0] = reputationOracle; + oracles[1] = recordingOracle; + oracles[2] = exchangeOracle; + + amounts[0] = _reputationOracleFee; + amounts[1] = _recordingOracleFee; + amounts[2] = _exchangeOracleFee; + + emit OracleFeeTransfer(oracles, amounts); + } + if (_remainingFunds > 0) { + tokenContract.safeTransfer(launcher, _remainingFunds); if (_status == EscrowStatuses.ToCancel) { - emit CancellationRefund(_remaining); + emit CancellationRefund(_remainingFunds); } - remainingFunds = 0; - reservedFunds = 0; } if (_status == EscrowStatuses.ToCancel) { @@ -383,22 +433,7 @@ contract Escrow is IEscrow, ReentrancyGuard { remainingFunds = reservedFunds; } if (remainingFunds == 0) { - status = EscrowStatuses.Cancelled; - emit Cancelled(); - } - } - } - - function _calculateTotalBulkAmount( - uint256[] calldata amounts - ) internal pure returns (uint256 total) { - uint256 len = amounts.length; - for (uint256 i; i < len; ) { - uint256 amount = amounts[i]; - require(amount > 0, 'Zero amount'); - total += amount; - unchecked { - ++i; + _finalize(); } } } @@ -444,78 +479,40 @@ contract Escrow is IEscrow, ReentrancyGuard { require(remainingFunds != 0, 'No funds'); require(!payouts[payoutId], 'payoutId already exists'); require(_recipients.length == _amounts.length, 'Length mismatch'); - require(_amounts.length > 0, 'Empty amounts'); - require(_recipients.length <= BULK_MAX_COUNT, 'Too many recipients'); + uint256 length = _amounts.length; + require(length > 0, 'Empty amounts'); + require(length <= BULK_MAX_COUNT, 'Too many recipients'); require( bytes(_url).length != 0 && bytes(_hash).length != 0, 'Empty url/hash' ); - uint256 totalBulkAmount = _calculateTotalBulkAmount(_amounts); - require(totalBulkAmount <= reservedFunds, 'Not enough funds'); - - uint256 length = _recipients.length; - uint256[] memory netAmounts = new uint256[](length + 3); - address[] memory eventRecipients = new address[](length + 3); - IERC20 erc20 = IERC20(token); - Fees memory fees; + uint256 totalBulkAmount; for (uint256 i; i < length; ) { uint256 amount = _amounts[i]; - uint256 reputationOracleFee = (reputationOracleFeePercentage * - amount) / 100; - uint256 recordingOracleFee = (recordingOracleFeePercentage * - amount) / 100; - uint256 exchangeOracleFee = (exchangeOracleFeePercentage * amount) / - 100; - - fees.reputation += reputationOracleFee; - fees.recording += recordingOracleFee; - fees.exchange += exchangeOracleFee; - - uint256 net = amount - - reputationOracleFee - - recordingOracleFee - - exchangeOracleFee; - netAmounts[i] = net; - address to = _recipients[i]; - eventRecipients[i] = to; - - erc20.safeTransfer(to, net); + require(amount > 0, 'Zero amount'); + totalBulkAmount += amount; unchecked { ++i; } } - if (reputationOracleFeePercentage > 0) { - erc20.safeTransfer(reputationOracle, fees.reputation); - eventRecipients[length] = reputationOracle; - netAmounts[length] = fees.reputation; - unchecked { - ++length; - } - } - if (recordingOracleFeePercentage > 0) { - erc20.safeTransfer(recordingOracle, fees.recording); - eventRecipients[length] = recordingOracle; - netAmounts[length] = fees.recording; - unchecked { - ++length; - } + require(totalBulkAmount <= reservedFunds, 'Not enough funds'); + + unchecked { + remainingFunds -= totalBulkAmount; + reservedFunds -= totalBulkAmount; } - if (exchangeOracleFeePercentage > 0) { - erc20.safeTransfer(exchangeOracle, fees.exchange); - eventRecipients[length] = exchangeOracle; - netAmounts[length] = fees.exchange; + + for (uint256 i; i < length; ) { + erc20.safeTransfer(_recipients[i], _amounts[i]); unchecked { - ++length; + ++i; } } - remainingFunds -= totalBulkAmount; - reservedFunds -= totalBulkAmount; - finalResultsUrl = _url; finalResultsHash = _hash; payouts[payoutId] = true; @@ -524,8 +521,8 @@ contract Escrow is IEscrow, ReentrancyGuard { emit BulkTransferV3( payoutId, - eventRecipients, - netAmounts, + _recipients, + _amounts, isPartial, _url, _hash diff --git a/packages/core/test/Escrow.ts b/packages/core/test/Escrow.ts index ff6f2c3af5..005f1e2c8d 100644 --- a/packages/core/test/Escrow.ts +++ b/packages/core/test/Escrow.ts @@ -6,11 +6,16 @@ import { faker } from '@faker-js/faker'; const BULK_MAX_COUNT = 100; const STANDARD_DURATION = 100; +const ORACLE_FEE_PERCENTAGE = 3n; const FIXTURE_URL = faker.internet.url(); const FIXTURE_HASH = faker.string.alphanumeric(10); const FIXTURE_FUND_AMOUNT = ethers.parseEther('100'); +function calculateOracleFee(amount: bigint): bigint { + return (amount * ORACLE_FEE_PERCENTAGE) / 100n; +} + enum Status { Launched = 0, Pending = 1, @@ -329,6 +334,11 @@ describe('Escrow', function () { expect(await escrow.status()).to.equal(Status.Pending); expect(await escrow.manifest()).to.equal(FIXTURE_URL); expect(await escrow.manifestHash()).to.equal(FIXTURE_HASH); + expect(await escrow.fundAmount()).to.equal(amount); + expect(await escrow.remainingFunds()).to.equal( + amount - calculateOracleFee(amount) * 3n + ); + expect(await escrow.reservedFunds()).to.equal(0); }); it('Admin: sets up successfully', async () => { @@ -361,6 +371,11 @@ describe('Escrow', function () { expect(await escrow.status()).to.equal(Status.Pending); expect(await escrow.manifest()).to.equal(FIXTURE_URL); expect(await escrow.manifestHash()).to.equal(FIXTURE_HASH); + expect(await escrow.fundAmount()).to.equal(amount); + expect(await escrow.remainingFunds()).to.equal( + amount - calculateOracleFee(amount) * 3n + ); + expect(await escrow.reservedFunds()).to.equal(0); }); }); }); @@ -438,43 +453,89 @@ describe('Escrow', function () { }); describe('succeeds', () => { it('Recording oracle: stores results successfully', async () => { - await expect( - storeResults(FIXTURE_URL, FIXTURE_HASH, FIXTURE_FUND_AMOUNT) - ) + const workerFunds = await escrow.remainingFunds(); + await expect(storeResults(FIXTURE_URL, FIXTURE_HASH, workerFunds)) .to.emit(escrow, 'IntermediateStorage') .withArgs(FIXTURE_URL, FIXTURE_HASH); expect(await escrow.intermediateResultsUrl()).to.equal(FIXTURE_URL); - expect(await escrow.reservedFunds()).to.equal(FIXTURE_FUND_AMOUNT); + expect(await escrow.reservedFunds()).to.equal(workerFunds); }); it('Recording oracle: stores results successfully and cancels the escrow', async () => { const launcherInitialBalance = await token.balanceOf(launcher); + const workerFunds = await escrow.remainingFunds(); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) + ); + await escrow.connect(launcher).requestCancellation(); await expect(storeResults()) .to.emit(escrow, 'IntermediateStorage') .withArgs(FIXTURE_URL, FIXTURE_HASH) .to.emit(escrow, 'CancellationRefund') - .withArgs(FIXTURE_FUND_AMOUNT); + .withArgs(workerFunds) + .to.emit(escrow, 'OracleFeeTransfer') + .withArgs( + [ + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + ], + [ + calculateOracleFee(FIXTURE_FUND_AMOUNT), + calculateOracleFee(FIXTURE_FUND_AMOUNT), + calculateOracleFee(FIXTURE_FUND_AMOUNT), + ] + ); + + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) + ); + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); + expect(await escrow.intermediateResultsUrl()).to.equal(FIXTURE_URL); expect(await escrow.status()).to.equal(Status.Cancelled); expect(await escrow.remainingFunds()).to.equal(ethers.parseEther('0')); expect(await token.balanceOf(launcher)).to.equal( - launcherInitialBalance + FIXTURE_FUND_AMOUNT + launcherInitialBalance + workerFunds ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index] - initialBalance).to.equal( + oracleExpectedFee + ); + }); }); it('Admin: stores results successfully', async () => { + const workerFunds = await escrow.remainingFunds(); await expect( - storeResults(FIXTURE_URL, FIXTURE_HASH, FIXTURE_FUND_AMOUNT, admin) + storeResults(FIXTURE_URL, FIXTURE_HASH, workerFunds, admin) ) .to.emit(escrow, 'IntermediateStorage') .withArgs(FIXTURE_URL, FIXTURE_HASH); expect(await escrow.intermediateResultsUrl()).to.equal(FIXTURE_URL); - expect(await escrow.reservedFunds()).to.equal(FIXTURE_FUND_AMOUNT); + expect(await escrow.reservedFunds()).to.equal(workerFunds); }); it('Admin: stores results successfully and cancels the escrow', async () => { const launcherInitialBalance = await token.balanceOf(launcher); + const workerFunds = await escrow.remainingFunds(); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) + ); + await escrow.connect(launcher).requestCancellation(); await expect( storeResults(FIXTURE_URL, FIXTURE_HASH, ethers.parseEther('0'), admin) @@ -482,13 +543,28 @@ describe('Escrow', function () { .to.emit(escrow, 'IntermediateStorage') .withArgs(FIXTURE_URL, FIXTURE_HASH) .to.emit(escrow, 'CancellationRefund') - .withArgs(FIXTURE_FUND_AMOUNT); + .withArgs(workerFunds); + + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) + ); + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); + expect(await escrow.intermediateResultsUrl()).to.equal(FIXTURE_URL); expect(await escrow.status()).to.equal(Status.Cancelled); expect(await escrow.remainingFunds()).to.equal(ethers.parseEther('0')); expect(await token.balanceOf(launcher)).to.equal( - launcherInitialBalance + FIXTURE_FUND_AMOUNT + launcherInitialBalance + workerFunds ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index] - initialBalance).to.equal( + oracleExpectedFee + ); + }); }); }); }); @@ -517,13 +593,13 @@ describe('Escrow', function () { }); it('reverts when escrow has no funds (complete or cancelled)', async function () { - const balance = await token.balanceOf(escrow.getAddress()); - await storeResults(FIXTURE_URL, FIXTURE_HASH, balance); + const workerFunds = await escrow.remainingFunds(); + await storeResults(FIXTURE_URL, FIXTURE_HASH, workerFunds); await escrow .connect(admin) [ 'bulkPayOut(address[],uint256[],string,string,string,bool)' - ]([externalAddress], [balance], FIXTURE_URL, FIXTURE_HASH, '000', false); + ]([externalAddress], [workerFunds], FIXTURE_URL, FIXTURE_HASH, '000', false); await expect( escrow.connect(launcher).requestCancellation() ).to.be.revertedWith('Invalid status'); @@ -559,7 +635,9 @@ describe('Escrow', function () { expect(await token.balanceOf(escrow.getAddress())).to.equal(0); expect(await token.balanceOf(launcherAddress)).to.equal( - launcherBalance + FIXTURE_FUND_AMOUNT + launcherBalance + + FIXTURE_FUND_AMOUNT - + calculateOracleFee(FIXTURE_FUND_AMOUNT) * 3n ); }); @@ -591,7 +669,9 @@ describe('Escrow', function () { expect(await token.balanceOf(escrow.getAddress())).to.equal(0); expect(await token.balanceOf(launcherAddress)).to.equal( - launcherBalance + FIXTURE_FUND_AMOUNT + launcherBalance + + FIXTURE_FUND_AMOUNT - + calculateOracleFee(FIXTURE_FUND_AMOUNT) * 3n ); }); @@ -783,7 +863,11 @@ describe('Escrow', function () { }); it('reverts when payoutId exists', async function () { - await storeResults(FIXTURE_URL, FIXTURE_HASH, FIXTURE_FUND_AMOUNT); + await storeResults( + FIXTURE_URL, + FIXTURE_HASH, + await escrow.remainingFunds() + ); await escrow .connect(reputationOracle) [ @@ -910,14 +994,7 @@ describe('Escrow', function () { const initialBalances = await Promise.all( recipients.map((r) => token.balanceOf(r)) ); - - const initialOracleBalances = await Promise.all( - [ - recordingOracleAddress, - reputationOracleAddress, - exchangeOracleAddress, - ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) - ); + const workerFunds = await escrow.remainingFunds(); await storeResults(FIXTURE_URL, FIXTURE_HASH, totalAmount); await expect( @@ -931,31 +1008,15 @@ describe('Escrow', function () { const finalBalances = await Promise.all( recipients.map((r) => token.balanceOf(r)) ); - const finalOracleBalances = await Promise.all( - [ - recordingOracleAddress, - reputationOracleAddress, - exchangeOracleAddress, - ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) - ); - - const oracleExpectedFee = (totalAmount * 3n) / 100n; // 3% fee - recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); }); - initialOracleBalances.forEach((initialBalance, index) => { - expect( - (finalOracleBalances[index] - initialBalance).toString() - ).to.equal(oracleExpectedFee.toString()); - }); - expect(await escrow.remainingFunds()).to.equal( - await escrow.getBalance() + workerFunds - totalAmount ); expect(await escrow.status()).to.equal(Status.Partial); }); @@ -964,7 +1025,7 @@ describe('Escrow', function () { const amounts = [ ethers.parseEther('40'), ethers.parseEther('30'), - ethers.parseEther('30'), + ethers.parseEther('21'), ]; const initialBalances = await Promise.all( recipients.map((r) => token.balanceOf(r)) @@ -990,7 +1051,21 @@ describe('Escrow', function () { [ 'bulkPayOut(address[],uint256[],string,string,string,bool)' ](recipients, amounts, FIXTURE_URL, FIXTURE_HASH, '000', false) - ).to.emit(escrow, 'BulkTransferV3'); + ) + .to.emit(escrow, 'BulkTransferV3') + .to.emit(escrow, 'OracleFeeTransfer') + .withArgs( + [ + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + ], + [ + calculateOracleFee(FIXTURE_FUND_AMOUNT), + calculateOracleFee(FIXTURE_FUND_AMOUNT), + calculateOracleFee(FIXTURE_FUND_AMOUNT), + ] + ); const finalBalances = await Promise.all( recipients.map((r) => token.balanceOf(r)) @@ -1003,10 +1078,10 @@ describe('Escrow', function () { ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) ); - const oracleExpectedFee = (totalPayout * 3n) / 100n; // 3% fee + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); @@ -1058,10 +1133,10 @@ describe('Escrow', function () { ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) ); - const oracleExpectedFee = (totalAmount * 3n) / 100n; // 3% fee + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); @@ -1080,7 +1155,10 @@ describe('Escrow', function () { const launcherFinalBalance = await token.balanceOf(launcherAddress); expect(launcherFinalBalance).to.equal( - launcherInitialBalance + (FIXTURE_FUND_AMOUNT - totalAmount) + launcherInitialBalance + + FIXTURE_FUND_AMOUNT - + calculateOracleFee(FIXTURE_FUND_AMOUNT) * 3n - + totalAmount ); }); @@ -1119,10 +1197,10 @@ describe('Escrow', function () { ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) ); - const oracleExpectedFee = (totalAmount * 3n) / 100n; // 3% fee + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); @@ -1144,14 +1222,7 @@ describe('Escrow', function () { const initialBalances = await Promise.all( recipients.map((r) => token.balanceOf(r)) ); - - const initialOracleBalances = await Promise.all( - [ - recordingOracleAddress, - reputationOracleAddress, - exchangeOracleAddress, - ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) - ); + const workerFunds = await escrow.remainingFunds(); await storeResults(FIXTURE_URL, FIXTURE_HASH, totalAmount); @@ -1166,31 +1237,16 @@ describe('Escrow', function () { const finalBalances = await Promise.all( recipients.map((r) => token.balanceOf(r)) ); - const finalOracleBalances = await Promise.all( - [ - recordingOracleAddress, - reputationOracleAddress, - exchangeOracleAddress, - ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) - ); - - const oracleExpectedFee = (totalAmount * 3n) / 100n; // 3% fee recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); }); - initialOracleBalances.forEach((initialBalance, index) => { - expect( - (finalOracleBalances[index] - initialBalance).toString() - ).to.equal(oracleExpectedFee.toString()); - }); - expect(await escrow.remainingFunds()).to.equal( - await escrow.getBalance() + workerFunds - totalAmount ); expect(await escrow.status()).to.equal(Status.Partial); }); @@ -1199,7 +1255,7 @@ describe('Escrow', function () { const amounts = [ ethers.parseEther('40'), ethers.parseEther('30'), - ethers.parseEther('30'), + ethers.parseEther('21'), ]; const initialBalances = await Promise.all( recipients.map((r) => token.balanceOf(r)) @@ -1238,10 +1294,10 @@ describe('Escrow', function () { ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) ); - const oracleExpectedFee = (totalPayout * 3n) / 100n; // 3% fee + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); @@ -1293,10 +1349,10 @@ describe('Escrow', function () { ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) ); - const oracleExpectedFee = (totalAmount * 3n) / 100n; // 3% fee + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); @@ -1315,7 +1371,10 @@ describe('Escrow', function () { const launcherFinalBalance = await token.balanceOf(launcherAddress); expect(launcherFinalBalance).to.equal( - launcherInitialBalance + (FIXTURE_FUND_AMOUNT - totalAmount) + launcherInitialBalance + + FIXTURE_FUND_AMOUNT - + calculateOracleFee(FIXTURE_FUND_AMOUNT) * 3n - + totalAmount ); }); @@ -1354,10 +1413,10 @@ describe('Escrow', function () { ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) ); - const oracleExpectedFee = (totalAmount * 3n) / 100n; // 3% fee + const oracleExpectedFee = calculateOracleFee(FIXTURE_FUND_AMOUNT); recipients.forEach((_, index) => { - const expectedAmount = (BigInt(amounts[index]) * 91n) / 100n; // 91% after all 3 oracle fees + const expectedAmount = BigInt(amounts[index]); expect( (finalBalances[index] - initialBalances[index]).toString() ).to.equal(expectedAmount.toString()); @@ -1418,6 +1477,14 @@ describe('Escrow', function () { const amounts = [ethers.parseEther('10')]; const initialLauncherBalance = await token.balanceOf(launcherAddress); + const initialRecipientBalance = await token.balanceOf(recipients[0]); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) + ); const initialEscrowBalance = await token.balanceOf(escrow.getAddress()); await storeResults(FIXTURE_URL, FIXTURE_HASH, amounts[0]); @@ -1436,9 +1503,27 @@ describe('Escrow', function () { expect(await escrow.remainingFunds()).to.equal('0'); const finalLauncherBalance = await token.balanceOf(launcherAddress); + const finalRecipientBalance = await token.balanceOf(recipients[0]); + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.connect(owner).balanceOf(oracle)) + ); + expect(finalRecipientBalance - initialRecipientBalance).to.equal( + amounts[0] + ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index] - initialBalance).to.equal( + calculateOracleFee(initialEscrowBalance) + ); + }); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - amounts[0] + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n - + amounts[0] ); }); @@ -1466,7 +1551,9 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - amounts[0] + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n - + amounts[0] ); }); @@ -1487,7 +1574,7 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); }); @@ -1508,7 +1595,7 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); }); }); @@ -1558,7 +1645,10 @@ describe('Escrow', function () { await expect(escrow.connect(reputationOracle).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs(initialEscrowBalance) + .withArgs( + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n + ) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1568,7 +1658,7 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); }); @@ -1580,7 +1670,10 @@ describe('Escrow', function () { await expect(escrow.connect(admin).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs(initialEscrowBalance) + .withArgs( + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n + ) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1590,7 +1683,7 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); }); @@ -1600,11 +1693,18 @@ describe('Escrow', function () { escrow.getAddress() ); - await storeResults(FIXTURE_URL, FIXTURE_HASH, initialEscrowBalance); + await storeResults( + FIXTURE_URL, + FIXTURE_HASH, + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n + ); await expect(escrow.connect(reputationOracle).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs(initialEscrowBalance) + .withArgs( + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n + ) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1614,7 +1714,7 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); }); @@ -1624,11 +1724,18 @@ describe('Escrow', function () { escrow.getAddress() ); - await storeResults(FIXTURE_URL, FIXTURE_HASH, initialEscrowBalance); + await storeResults( + FIXTURE_URL, + FIXTURE_HASH, + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n + ); await expect(escrow.connect(admin).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs(initialEscrowBalance) + .withArgs( + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n + ) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1638,7 +1745,7 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); }); @@ -1648,7 +1755,11 @@ describe('Escrow', function () { escrow.getAddress() ); - await storeResults(FIXTURE_URL, FIXTURE_HASH, initialEscrowBalance); + await storeResults( + FIXTURE_URL, + FIXTURE_HASH, + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n + ); await escrow .connect(admin) [ @@ -1657,7 +1768,11 @@ describe('Escrow', function () { await expect(escrow.connect(reputationOracle).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs(initialEscrowBalance / 2n) + .withArgs( + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n - + initialEscrowBalance / 2n + ) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1667,7 +1782,9 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance / 2n + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n - + initialEscrowBalance / 2n ); }); @@ -1677,7 +1794,11 @@ describe('Escrow', function () { escrow.getAddress() ); - await storeResults(FIXTURE_URL, FIXTURE_HASH, initialEscrowBalance); + await storeResults( + FIXTURE_URL, + FIXTURE_HASH, + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n + ); await escrow .connect(admin) [ @@ -1686,7 +1807,11 @@ describe('Escrow', function () { await expect(escrow.connect(admin).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs(initialEscrowBalance / 2n) + .withArgs( + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n - + initialEscrowBalance / 2n + ) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1696,7 +1821,9 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance / 2n + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n - + initialEscrowBalance / 2n ); }); }); diff --git a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_client.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_client.py index 31cc4ad29c..c3028df90b 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_client.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/escrow/escrow_client.py @@ -948,6 +948,44 @@ def get_balance(self, escrow_address: str) -> int: return self._get_escrow_contract(escrow_address).functions.getBalance().call() + def get_remaining_funds(self, escrow_address: str) -> int: + """Get the remaining worker funds for a specified escrow. + + Args: + escrow_address (str): Address of the escrow. + + Returns: + Remaining worker funds in token's smallest unit. + + Raises: + EscrowClientError: If the escrow address is invalid. + """ + + if not Web3.is_address(escrow_address): + raise EscrowClientError(f"Invalid escrow address: {escrow_address}") + + return ( + self._get_escrow_contract(escrow_address).functions.remainingFunds().call() + ) + + def get_fund_amount(self, escrow_address: str) -> int: + """Get the original funded amount for a specified escrow. + + Args: + escrow_address (str): Address of the escrow. + + Returns: + Original funded amount in token's smallest unit. + + Raises: + EscrowClientError: If the escrow address is invalid. + """ + + if not Web3.is_address(escrow_address): + raise EscrowClientError(f"Invalid escrow address: {escrow_address}") + + return self._get_escrow_contract(escrow_address).functions.fundAmount().call() + def get_reserved_funds(self, escrow_address: str) -> int: """Get the reserved funds for a specified escrow. diff --git a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_client.py b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_client.py index 1dfc881c5d..84d1226936 100644 --- a/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_client.py +++ b/packages/sdk/python/human-protocol-sdk/test/human_protocol_sdk/escrow/test_escrow_client.py @@ -2267,6 +2267,60 @@ def test_get_balance_new_escrow(self): mock_contract.functions.remainingFunds.assert_called_once_with() self.assertEqual(result, 100) + def test_get_remaining_funds(self): + mock_contract = MagicMock() + mock_contract.functions.remainingFunds = MagicMock() + mock_contract.functions.remainingFunds.return_value.call.return_value = 100 + self.escrow._get_escrow_contract = MagicMock(return_value=mock_contract) + escrow_address = "0x1234567890123456789012345678901234567890" + + result = self.escrow.get_remaining_funds(escrow_address) + + self.escrow._get_escrow_contract.assert_called_once_with(escrow_address) + mock_contract.functions.remainingFunds.assert_called_once_with() + self.assertEqual(result, 100) + + def test_get_remaining_funds_invalid_address(self): + with self.assertRaises(EscrowClientError) as cm: + self.escrow.get_remaining_funds("invalid_address") + self.assertEqual(f"Invalid escrow address: invalid_address", str(cm.exception)) + + def test_get_remaining_funds_invalid_escrow(self): + self.escrow.factory_contract.functions.hasEscrow = MagicMock(return_value=False) + with self.assertRaises(EscrowClientError) as cm: + self.escrow.get_remaining_funds( + "0x1234567890123456789012345678901234567890" + ) + self.assertEqual( + "Escrow address is not provided by the factory", str(cm.exception) + ) + + def test_get_fund_amount(self): + mock_contract = MagicMock() + mock_contract.functions.fundAmount = MagicMock() + mock_contract.functions.fundAmount.return_value.call.return_value = 100 + self.escrow._get_escrow_contract = MagicMock(return_value=mock_contract) + escrow_address = "0x1234567890123456789012345678901234567890" + + result = self.escrow.get_fund_amount(escrow_address) + + self.escrow._get_escrow_contract.assert_called_once_with(escrow_address) + mock_contract.functions.fundAmount.assert_called_once_with() + self.assertEqual(result, 100) + + def test_get_fund_amount_invalid_address(self): + with self.assertRaises(EscrowClientError) as cm: + self.escrow.get_fund_amount("invalid_address") + self.assertEqual(f"Invalid escrow address: invalid_address", str(cm.exception)) + + def test_get_fund_amount_invalid_escrow(self): + self.escrow.factory_contract.functions.hasEscrow = MagicMock(return_value=False) + with self.assertRaises(EscrowClientError) as cm: + self.escrow.get_fund_amount("0x1234567890123456789012345678901234567890") + self.assertEqual( + "Escrow address is not provided by the factory", str(cm.exception) + ) + def test_get_manifest_hash(self): mock_contract = MagicMock() mock_contract.functions.manifestHash = MagicMock() diff --git a/packages/sdk/typescript/human-protocol-sdk/src/escrow/escrow_client.ts b/packages/sdk/typescript/human-protocol-sdk/src/escrow/escrow_client.ts index c00bdd0c93..1ded4b4374 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/escrow/escrow_client.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/escrow/escrow_client.ts @@ -1195,6 +1195,56 @@ export class EscrowClient extends BaseEthersClient { } } + /** + * This function returns the remaining funds for a specified escrow address. + * + * @param escrowAddress - Address of the escrow. + * @returns Remaining worker funds of the escrow. + * @throws ErrorInvalidEscrowAddressProvided If the escrow address is invalid + * @throws ErrorEscrowAddressIsNotProvidedByFactory If the escrow is not provided by the factory + */ + async getRemainingFunds(escrowAddress: string): Promise { + if (!ethers.isAddress(escrowAddress)) { + throw ErrorInvalidEscrowAddressProvided; + } + + if (!(await this.escrowFactoryContract.hasEscrow(escrowAddress))) { + throw ErrorEscrowAddressIsNotProvidedByFactory; + } + + try { + const escrowContract = this.getEscrowContract(escrowAddress); + return await escrowContract.remainingFunds(); + } catch (e) { + return throwError(e); + } + } + + /** + * This function returns the original funded amount for a specified escrow address. + * + * @param escrowAddress - Address of the escrow. + * @returns Original amount used to fund the escrow. + * @throws ErrorInvalidEscrowAddressProvided If the escrow address is invalid + * @throws ErrorEscrowAddressIsNotProvidedByFactory If the escrow is not provided by the factory + */ + async getFundAmount(escrowAddress: string): Promise { + if (!ethers.isAddress(escrowAddress)) { + throw ErrorInvalidEscrowAddressProvided; + } + + if (!(await this.escrowFactoryContract.hasEscrow(escrowAddress))) { + throw ErrorEscrowAddressIsNotProvidedByFactory; + } + + try { + const escrowContract = this.getEscrowContract(escrowAddress); + return await escrowContract.fundAmount(); + } catch (e) { + return throwError(e); + } + } + /** * This function returns the reserved funds for a specified escrow address. * diff --git a/packages/sdk/typescript/human-protocol-sdk/test/escrow.test.ts b/packages/sdk/typescript/human-protocol-sdk/test/escrow.test.ts index 15e16a301c..6768f22e26 100644 --- a/packages/sdk/typescript/human-protocol-sdk/test/escrow.test.ts +++ b/packages/sdk/typescript/human-protocol-sdk/test/escrow.test.ts @@ -101,6 +101,7 @@ describe('EscrowClient', () => { requestCancellation: vi.fn(), withdraw: vi.fn(), getBalance: vi.fn(), + fundAmount: vi.fn(), remainingFunds: vi.fn(), reservedFunds: vi.fn(), manifestHash: vi.fn(), @@ -2798,6 +2799,74 @@ describe('EscrowClient', () => { }); }); + describe('getRemainingFunds', () => { + test('should throw an error if escrowAddress is an invalid address', async () => { + const escrowAddress = FAKE_ADDRESS; + + await expect( + escrowClient.getRemainingFunds(escrowAddress) + ).rejects.toThrow(ErrorInvalidEscrowAddressProvided); + }); + + test('should throw an error if hasEscrow returns false', async () => { + const escrowAddress = ethers.ZeroAddress; + + escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(false); + + await expect( + escrowClient.getRemainingFunds(escrowAddress) + ).rejects.toThrow(ErrorEscrowAddressIsNotProvidedByFactory); + }); + + test('should successfully getRemainingFunds', async () => { + const escrowAddress = ethers.ZeroAddress; + const remainingFunds = 123n; + + escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(true); + escrowClient.escrowContract.remainingFunds.mockResolvedValueOnce( + remainingFunds + ); + + const result = await escrowClient.getRemainingFunds(escrowAddress); + + expect(result).toEqual(remainingFunds); + expect(escrowClient.escrowContract.remainingFunds).toHaveBeenCalledWith(); + }); + }); + + describe('getFundAmount', () => { + test('should throw an error if escrowAddress is an invalid address', async () => { + const escrowAddress = FAKE_ADDRESS; + + await expect(escrowClient.getFundAmount(escrowAddress)).rejects.toThrow( + ErrorInvalidEscrowAddressProvided + ); + }); + + test('should throw an error if hasEscrow returns false', async () => { + const escrowAddress = ethers.ZeroAddress; + + escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(false); + + await expect(escrowClient.getFundAmount(escrowAddress)).rejects.toThrow( + ErrorEscrowAddressIsNotProvidedByFactory + ); + }); + + test('should successfully getFundAmount', async () => { + const escrowAddress = ethers.ZeroAddress; + const fundAmount = 456n; + + escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(true); + escrowClient.escrowContract.fundAmount.mockResolvedValueOnce(fundAmount); + + const result = await escrowClient.getFundAmount(escrowAddress); + + expect(result).toEqual(fundAmount); + expect(escrowClient.escrowContract.fundAmount).toHaveBeenCalledWith(); + }); + }); + describe('getManifestHash', () => { test('should throw an error if escrowAddress is an invalid address', async () => { const escrowAddress = FAKE_ADDRESS; diff --git a/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts b/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts index c22e70390d..47ebaded63 100644 --- a/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts +++ b/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts @@ -13,6 +13,7 @@ import { Withdraw, CancellationRequested, CancellationRefund, + OracleFeeTransfer, } from '../../generated/templates/Escrow/Escrow'; import { CancellationRefundEvent, @@ -24,6 +25,7 @@ import { DailyWorker, InternalTransaction, Operator, + Transaction, } from '../../generated/schema'; import { Address, @@ -842,3 +844,58 @@ export function handleCancellationRefund(event: CancellationRefund): void { entity.amount = event.params.amount; entity.save(); } + +export function handleOracleFeeTransfer(event: OracleFeeTransfer): void { + const escrowEntity = Escrow.load(dataSource.address()); + if (!escrowEntity) return; + + const eventDayData = getEventDayData(event); + const originalLogIndex = event.logIndex; + + for (let i = 0; i < event.params.oracles.length; i++) { + const oracle = event.params.oracles[i]; + const amount = event.params.amounts[i]; + + if (amount.equals(ZERO_BI)) { + continue; + } + + event.logIndex = originalLogIndex.plus(BigInt.fromI32(1000000 + i)); + const payoutId = toEventId(event); + const payout = new Payout(payoutId); + payout.escrowAddress = event.address; + payout.recipient = oracle; + payout.amount = amount; + payout.createdAt = event.block.timestamp; + payout.save(); + + createTransaction( + event, + 'transfer', + Address.fromBytes(escrowEntity.address), + oracle, + oracle, + Address.fromBytes(escrowEntity.address), + amount, + Address.fromBytes(escrowEntity.token) + ); + + escrowEntity.balance = escrowEntity.balance.minus(amount); + escrowEntity.amountPaid = escrowEntity.amountPaid.plus(amount); + eventDayData.dailyPayoutCount = eventDayData.dailyPayoutCount.plus(ONE_BI); + } + + event.logIndex = originalLogIndex; + const transaction = Transaction.load(event.transaction.hash); + if ( + transaction && + transaction.method == 'multimethod' && + Address.fromBytes(transaction.to) == Address.zero() + ) { + transaction.to = escrowEntity.address; + transaction.save(); + } + + escrowEntity.save(); + eventDayData.save(); +} diff --git a/packages/subgraph/human-protocol/template.yaml b/packages/subgraph/human-protocol/template.yaml index f4fde48c1c..3159c3bb57 100644 --- a/packages/subgraph/human-protocol/template.yaml +++ b/packages/subgraph/human-protocol/template.yaml @@ -160,6 +160,8 @@ templates: handler: handleCancellationRequested - event: CancellationRefund(uint256) handler: handleCancellationRefund + - event: OracleFeeTransfer(address[],uint256[]) + handler: handleOracleFeeTransfer - event: Cancelled() handler: handleCancelled - event: Completed() diff --git a/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts b/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts index f3b768107a..1a427d2e26 100644 --- a/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts +++ b/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts @@ -29,6 +29,7 @@ import { handleCompleted, handleFund, handleIntermediateStorage, + handleOracleFeeTransfer, handlePending, handlePendingV2, handlePendingV3, @@ -46,6 +47,7 @@ import { createCompletedEvent, createFundEvent, createISEvent, + createOracleFeeTransferEvent, createPendingEvent, createPendingV2Event, createPendingV3Event, @@ -1414,6 +1416,127 @@ describe('Escrow', () => { ); }); + test('Should properly handle OracleFeeTransfer event', () => { + const escrow = Escrow.load(escrowAddress); + escrow!.balance = BigInt.fromI32(100); + escrow!.token = tokenAddress; + escrow!.save(); + + const oracleFeeTransfer = createOracleFeeTransferEvent( + escrowAddress, + operatorAddress, + [reputationOracleAddress, recordingOracleAddress, exchangeOracleAddress], + [3, 3, 0], + BigInt.fromI32(86400) + ); + + handleOracleFeeTransfer(oracleFeeTransfer); + + const secondTransferTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 1000001) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + const firstTransferTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 1000000) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + const skippedTransferTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 1000002) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + + assert.fieldEquals( + 'Payout', + firstTransferTransactionId, + 'escrowAddress', + escrowAddressString + ); + assert.fieldEquals( + 'Payout', + firstTransferTransactionId, + 'recipient', + reputationOracleAddressString + ); + assert.fieldEquals('Payout', firstTransferTransactionId, 'amount', '3'); + assert.fieldEquals( + 'Payout', + secondTransferTransactionId, + 'recipient', + recordingOracleAddressString + ); + assert.fieldEquals('Payout', secondTransferTransactionId, 'amount', '3'); + assert.notInStore('Payout', skippedTransferTransactionId); + + assert.fieldEquals('Escrow', escrowAddressString, 'balance', '94'); + assert.fieldEquals( + 'Transaction', + oracleFeeTransfer.transaction.hash.toHex(), + 'method', + 'multimethod' + ); + assert.fieldEquals( + 'Transaction', + oracleFeeTransfer.transaction.hash.toHex(), + 'to', + escrowAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + firstTransferTransactionId, + 'method', + 'transfer' + ); + assert.fieldEquals( + 'InternalTransaction', + firstTransferTransactionId, + 'receiver', + reputationOracleAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + firstTransferTransactionId, + 'transaction', + oracleFeeTransfer.transaction.hash.toHex() + ); + assert.fieldEquals( + 'InternalTransaction', + secondTransferTransactionId, + 'receiver', + recordingOracleAddressString + ); + assert.notInStore('InternalTransaction', skippedTransferTransactionId); + assert.notInStore('Transaction', firstTransferTransactionId); + assert.fieldEquals( + 'EventDayData', + Bytes.fromI32(1).toHex(), + 'dailyPayoutCount', + '2' + ); + + const completed = createCompletedEvent( + operatorAddress, + BigInt.fromI32(86400) + ); + completed.transaction.hash = oracleFeeTransfer.transaction.hash; + completed.block.timestamp = oracleFeeTransfer.block.timestamp; + completed.logIndex = oracleFeeTransfer.logIndex.plus(BigInt.fromI32(1)); + + handleCompleted(completed); + + assert.fieldEquals( + 'Transaction', + oracleFeeTransfer.transaction.hash.toHex(), + 'method', + 'complete' + ); + assert.fieldEquals( + 'InternalTransaction', + firstTransferTransactionId, + 'method', + 'transfer' + ); + }); + test('Should properly handle Cancelled event', () => { const newCancelled = createCancelledEvent(operatorAddress); diff --git a/packages/subgraph/human-protocol/tests/escrow/fixtures.ts b/packages/subgraph/human-protocol/tests/escrow/fixtures.ts index 816f9b4412..e46446d298 100644 --- a/packages/subgraph/human-protocol/tests/escrow/fixtures.ts +++ b/packages/subgraph/human-protocol/tests/escrow/fixtures.ts @@ -10,6 +10,7 @@ import { Completed, Fund, IntermediateStorage, + OracleFeeTransfer, Pending, PendingV2, PendingV3, @@ -489,3 +490,38 @@ export function createCancellationRefundEvent( event.parameters.push(amountParam); return event; } + +export function createOracleFeeTransferEvent( + escrowAddress: Address, + sender: Address, + oracles: Address[], + amounts: i32[], + timestamp: BigInt +): OracleFeeTransfer { + const event = changetype(newMockEvent()); + event.address = escrowAddress; + event.transaction.from = sender; + event.transaction.to = escrowAddress; + event.transaction.hash = generateUniqueHash( + sender.toString() + '-oracle-fee-transfer', + timestamp, + event.transaction.nonce + ); + event.block.timestamp = timestamp; + + event.parameters = []; + + const oraclesParam = new ethereum.EventParam( + 'oracles', + ethereum.Value.fromAddressArray(oracles) + ); + const amountsParam = new ethereum.EventParam( + 'amounts', + ethereum.Value.fromI32Array(amounts) + ); + + event.parameters.push(oraclesParam); + event.parameters.push(amountsParam); + + return event; +} From f015bb8f3eb88eff89a9da8b33d7d9f7ed4a9da0 Mon Sep 17 00:00:00 2001 From: flopez7 Date: Wed, 13 May 2026 14:33:08 +0200 Subject: [PATCH 2/6] Refactor transaction logic when OracleFeeTransfer is emitted before Complete/Cancel in the same transaction --- .../src/modules/job/job.service.spec.ts | 5 ++-- .../src/mapping/EscrowTemplate.ts | 30 ++++++++++++------- .../tests/escrow/escrow.test.ts | 6 ++-- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index 13bd717a4d..1ac1487f52 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -348,13 +348,13 @@ describe('JobService', () => { }; mockWeb3Service.findAvailableOracles.mockResolvedValueOnce([ { - role: Role.ExchangeOracle, address: mockOracles.exchangeOracle, + role: Role.ExchangeOracle, url: null, }, { - role: Role.RecordingOracle, address: mockOracles.recordingOracle, + role: Role.RecordingOracle, url: null, }, ]); @@ -487,6 +487,7 @@ describe('JobService', () => { mul(div(1, 100), jobManifestDto.paymentAmount), ).toFixed(fundTokenDecimals), ); + expect(result).toBe(jobEntityMock.id); expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith( jobManifestDto.chainId, diff --git a/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts b/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts index 47ebaded63..c6ac9f63ed 100644 --- a/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts +++ b/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts @@ -852,6 +852,24 @@ export function handleOracleFeeTransfer(event: OracleFeeTransfer): void { const eventDayData = getEventDayData(event); const originalLogIndex = event.logIndex; + // OracleFeeTransfer is emitted before Complete/Cancel in the same transaction. + // Create a temporary multimethod parent so oracle fee transfers are stored as + // internal transactions until the main Complete/Cancel handler replaces it. + let transaction = Transaction.load(event.transaction.hash); + if (!transaction) { + transaction = new Transaction(event.transaction.hash); + transaction.txHash = event.transaction.hash; + transaction.block = event.block.number; + transaction.timestamp = event.block.timestamp; + transaction.from = event.transaction.from; + transaction.to = escrowEntity.address; + transaction.method = 'multimethod'; + transaction.value = ZERO_BI; + transaction.token = null; + transaction.escrow = null; + transaction.save(); + } + for (let i = 0; i < event.params.oracles.length; i++) { const oracle = event.params.oracles[i]; const amount = event.params.amounts[i]; @@ -860,7 +878,7 @@ export function handleOracleFeeTransfer(event: OracleFeeTransfer): void { continue; } - event.logIndex = originalLogIndex.plus(BigInt.fromI32(1000000 + i)); + event.logIndex = originalLogIndex.plus(BigInt.fromI32(10000 + i)); const payoutId = toEventId(event); const payout = new Payout(payoutId); payout.escrowAddress = event.address; @@ -886,16 +904,6 @@ export function handleOracleFeeTransfer(event: OracleFeeTransfer): void { } event.logIndex = originalLogIndex; - const transaction = Transaction.load(event.transaction.hash); - if ( - transaction && - transaction.method == 'multimethod' && - Address.fromBytes(transaction.to) == Address.zero() - ) { - transaction.to = escrowEntity.address; - transaction.save(); - } - escrowEntity.save(); eventDayData.save(); } diff --git a/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts b/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts index 1a427d2e26..b038ff3eda 100644 --- a/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts +++ b/packages/subgraph/human-protocol/tests/escrow/escrow.test.ts @@ -1433,15 +1433,15 @@ describe('Escrow', () => { handleOracleFeeTransfer(oracleFeeTransfer); const secondTransferTransactionId = oracleFeeTransfer.transaction.hash - .concatI32(oracleFeeTransfer.logIndex.toI32() + 1000001) + .concatI32(oracleFeeTransfer.logIndex.toI32() + 10001) .concatI32(oracleFeeTransfer.block.timestamp.toI32()) .toHex(); const firstTransferTransactionId = oracleFeeTransfer.transaction.hash - .concatI32(oracleFeeTransfer.logIndex.toI32() + 1000000) + .concatI32(oracleFeeTransfer.logIndex.toI32() + 10000) .concatI32(oracleFeeTransfer.block.timestamp.toI32()) .toHex(); const skippedTransferTransactionId = oracleFeeTransfer.transaction.hash - .concatI32(oracleFeeTransfer.logIndex.toI32() + 1000002) + .concatI32(oracleFeeTransfer.logIndex.toI32() + 10002) .concatI32(oracleFeeTransfer.block.timestamp.toI32()) .toHex(); From 0042ec095017e3c4772744fc67d084de652cca10 Mon Sep 17 00:00:00 2001 From: flopez7 Date: Wed, 13 May 2026 15:38:42 +0200 Subject: [PATCH 3/6] Refactor getNetFundAmount to use totalFundedAmount from subgraph instead of rpc --- .../src/modules/job/job.service.spec.ts | 12 +++--------- .../recording-oracle/src/modules/job/job.service.ts | 5 ++--- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts index 934f9313f1..078ff2eeda 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.spec.ts @@ -332,7 +332,6 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://example.com/results'), storeResults: jest.fn().mockResolvedValue(true), - getFundAmount: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); @@ -387,11 +386,10 @@ describe('JobService', () => { getManifest: jest.fn().mockResolvedValue('http://example.com/manifest'), getIntermediateResultsUrl: jest.fn().mockResolvedValue(''), storeResults: jest.fn().mockResolvedValue(true), - getFundAmount: jest.fn().mockResolvedValue(fundAmount), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ - totalFundedAmount: 8n, + totalFundedAmount: fundAmount, recordingOracleFee: oracleFees[0], reputationOracleFee: oracleFees[1], exchangeOracleFee: oracleFees[2], @@ -463,11 +461,10 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getFundAmount: jest.fn().mockResolvedValue(fundAmount), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ - totalFundedAmount: 8n, + totalFundedAmount: fundAmount, recordingOracleFee: oracleFees[0], reputationOracleFee: oracleFees[1], exchangeOracleFee: oracleFees[2], @@ -558,7 +555,6 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getFundAmount: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); KVStoreUtils.get = jest @@ -651,11 +647,10 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getFundAmount: jest.fn().mockResolvedValue(fundAmount), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ - totalFundedAmount: 8n, + totalFundedAmount: fundAmount, recordingOracleFee: oracleFees[0], reputationOracleFee: oracleFees[1], exchangeOracleFee: oracleFees[2], @@ -744,7 +739,6 @@ describe('JobService', () => { .fn() .mockResolvedValue('http://existing-solutions'), storeResults: jest.fn().mockResolvedValue(true), - getFundAmount: jest.fn().mockResolvedValue(ethers.parseEther('10')), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); KVStoreUtils.get = jest diff --git a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts index a6d6b9c4b8..7f54c82721 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts @@ -321,7 +321,6 @@ export class JobService { chainId: number, escrowAddress: string, ): Promise { - const fundAmount = await escrowClient.getFundAmount(escrowAddress); const escrow = await EscrowUtils.getEscrow(chainId, escrowAddress); if (!escrow) { this.logger.error(ErrorJob.NotFound, { @@ -338,8 +337,8 @@ export class JobService { return oracleFees.reduce( (netFundAmount, fee) => - netFundAmount - (fundAmount * BigInt(fee || 1)) / 100n, - fundAmount, + netFundAmount - (escrow.totalFundedAmount * BigInt(fee || 1)) / 100n, + escrow.totalFundedAmount, ); } } From c6ce0dce8cc9d1b05e302119a5e1e271c0cb609b Mon Sep 17 00:00:00 2001 From: flopez7 Date: Thu, 14 May 2026 10:46:11 +0200 Subject: [PATCH 4/6] Fix internal transaction details in handleCancellationRefund to use canceler address and event amount --- .../subgraph/human-protocol/src/mapping/EscrowTemplate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts b/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts index c6ac9f63ed..c0610ebfbe 100644 --- a/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts +++ b/packages/subgraph/human-protocol/src/mapping/EscrowTemplate.ts @@ -825,9 +825,9 @@ export function handleCancellationRefund(event: CancellationRefund): void { ); const internalTransaction = new InternalTransaction(toEventId(event)); internalTransaction.from = escrowEntity.address; - internalTransaction.to = Address.fromBytes(escrowEntity.token); + internalTransaction.to = escrowEntity.canceler; internalTransaction.receiver = escrowEntity.canceler; - internalTransaction.value = escrowEntity.balance; + internalTransaction.value = event.params.amount; internalTransaction.transaction = transaction.id; internalTransaction.method = 'transfer'; internalTransaction.token = Address.fromBytes(escrowEntity.token); From c7d97cf56e19a95c08c34fd9b5a4291828d343ef Mon Sep 17 00:00:00 2001 From: flopez7 Date: Thu, 14 May 2026 10:54:27 +0200 Subject: [PATCH 5/6] Add changeset file --- .changeset/puny-horses-rhyme.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/puny-horses-rhyme.md diff --git a/.changeset/puny-horses-rhyme.md b/.changeset/puny-horses-rhyme.md new file mode 100644 index 0000000000..d8ccad1fda --- /dev/null +++ b/.changeset/puny-horses-rhyme.md @@ -0,0 +1,9 @@ +--- +"@human-protocol/core": major +"@human-protocol/sdk": minor +"@human-protocol/python-sdk": minor +--- + +Update escrow oracle fee handling so oracle fees are reserved independently from worker payouts. + +The escrow contract now reserves oracle fees separately from worker payouts and transfers them on finalization, including when worker submissions are rejected. The SDK adds escrow fund amount accessors so clients and oracles can read the original funded amount and remaining worker payout funds. From dbcf09b18aa734e03648d043c0e9e0e8461afa3b Mon Sep 17 00:00:00 2001 From: flopez7 Date: Fri, 15 May 2026 12:39:57 +0200 Subject: [PATCH 6/6] Enhance cancellation logic to check for reserved funds and update oracle fee calculations in Escrow contract --- packages/core/contracts/Escrow.sol | 41 ++-- packages/core/test/Escrow.ts | 371 ++++++++++++++++------------- 2 files changed, 237 insertions(+), 175 deletions(-) diff --git a/packages/core/contracts/Escrow.sol b/packages/core/contracts/Escrow.sol index 6feab48841..4e2e223240 100644 --- a/packages/core/contracts/Escrow.sol +++ b/packages/core/contracts/Escrow.sol @@ -299,6 +299,7 @@ contract Escrow is IEscrow, ReentrancyGuard { */ function cancel() external override notExpired adminOrReputationOracle { require(status == EscrowStatuses.ToCancel, 'Invalid status'); + require(reservedFunds == 0, 'Reserved funds'); _finalize(); } @@ -331,6 +332,9 @@ contract Escrow is IEscrow, ReentrancyGuard { recordingOracleFeePercentage) / 100; uint256 _exchangeOracleFee = (fundAmount * exchangeOracleFeePercentage) / 100; + uint256 _totalOracleFee = _reputationOracleFee + + _recordingOracleFee + + _exchangeOracleFee; IERC20 tokenContract = IERC20(token); @@ -338,33 +342,38 @@ contract Escrow is IEscrow, ReentrancyGuard { remainingFunds = 0; reservedFunds = 0; - if (_reputationOracleFee > 0) { - tokenContract.safeTransfer(reputationOracle, _reputationOracleFee); - } - if (_recordingOracleFee > 0) { - tokenContract.safeTransfer(recordingOracle, _recordingOracleFee); - } - if (_exchangeOracleFee > 0) { - tokenContract.safeTransfer(exchangeOracle, _exchangeOracleFee); - } - if ( - _reputationOracleFee > 0 || - _recordingOracleFee > 0 || - _exchangeOracleFee > 0 - ) { + if (bytes(intermediateResultsUrl).length != 0) { address[] memory oracles = new address[](3); uint256[] memory amounts = new uint256[](3); oracles[0] = reputationOracle; oracles[1] = recordingOracle; oracles[2] = exchangeOracle; - amounts[0] = _reputationOracleFee; amounts[1] = _recordingOracleFee; amounts[2] = _exchangeOracleFee; + if (_reputationOracleFee > 0) { + tokenContract.safeTransfer( + reputationOracle, + _reputationOracleFee + ); + } + if (_recordingOracleFee > 0) { + tokenContract.safeTransfer( + recordingOracle, + _recordingOracleFee + ); + } + if (_exchangeOracleFee > 0) { + tokenContract.safeTransfer(exchangeOracle, _exchangeOracleFee); + } + emit OracleFeeTransfer(oracles, amounts); + } else { + _remainingFunds += _totalOracleFee; } + if (_remainingFunds > 0) { tokenContract.safeTransfer(launcher, _remainingFunds); if (_status == EscrowStatuses.ToCancel) { @@ -478,8 +487,8 @@ contract Escrow is IEscrow, ReentrancyGuard { bytes32 payoutId = keccak256(bytes(_payoutId)); require(remainingFunds != 0, 'No funds'); require(!payouts[payoutId], 'payoutId already exists'); - require(_recipients.length == _amounts.length, 'Length mismatch'); uint256 length = _amounts.length; + require(_recipients.length == length, 'Length mismatch'); require(length > 0, 'Empty amounts'); require(length <= BULK_MAX_COUNT, 'Too many recipients'); require( diff --git a/packages/core/test/Escrow.ts b/packages/core/test/Escrow.ts index 005f1e2c8d..3cd488027e 100644 --- a/packages/core/test/Escrow.ts +++ b/packages/core/test/Escrow.ts @@ -525,6 +525,43 @@ describe('Escrow', function () { expect(await escrow.reservedFunds()).to.equal(workerFunds); }); + it('Recording oracle: stores empty results in ToCancel and cancels without oracle fees', async () => { + const launcherInitialBalance = await token.balanceOf(launcher); + const initialEscrowBalance = await token.balanceOf(escrow); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); + + await escrow.connect(launcher).requestCancellation(); + await expect(storeResults('', '', 0n)) + .to.emit(escrow, 'IntermediateStorage') + .withArgs('', '') + .to.emit(escrow, 'Cancelled'); + + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); + + expect(await escrow.intermediateResultsUrl()).to.equal(''); + expect(await escrow.status()).to.equal(Status.Cancelled); + expect(await escrow.remainingFunds()).to.equal(0); + expect(await token.balanceOf(escrow)).to.equal(0); + expect(await token.balanceOf(launcher)).to.equal( + launcherInitialBalance + initialEscrowBalance + ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index]).to.equal(initialBalance); + }); + }); + it('Admin: stores results successfully and cancels the escrow', async () => { const launcherInitialBalance = await token.balanceOf(launcher); const workerFunds = await escrow.remainingFunds(); @@ -622,23 +659,50 @@ describe('Escrow', function () { ); }); + it('Launcher: requests escrow cancellation successfully when escrow has reserved funds', async () => { + const workerFunds = await escrow.remainingFunds(); + await storeResults(FIXTURE_URL, FIXTURE_HASH, workerFunds); + + await expect(escrow.connect(launcher).requestCancellation()).to.emit( + escrow, + 'CancellationRequested' + ); + + expect(await escrow.status()).to.equal(Status.ToCancel); + }); + it('Launcher: cancels escrow succesfully when escrow is expired', async () => { await deployEscrow(tokenAddress, launcherAddress, adminAddress, 3); await fundEscrow(); await setupEscrow(); const launcherBalance = await token.balanceOf(launcherAddress); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); await expect(escrow.connect(launcher).requestCancellation()).to.emit( escrow, 'CancellationRequested' ); + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); expect(await escrow.status()).to.equal(Status.Cancelled); expect(await token.balanceOf(escrow.getAddress())).to.equal(0); expect(await token.balanceOf(launcherAddress)).to.equal( - launcherBalance + - FIXTURE_FUND_AMOUNT - - calculateOracleFee(FIXTURE_FUND_AMOUNT) * 3n + launcherBalance + FIXTURE_FUND_AMOUNT ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index]).to.equal(initialBalance); + }); }); it('Admin: requests escrow cancellation succesfully', async () => { @@ -661,18 +725,33 @@ describe('Escrow', function () { await fundEscrow(); await setupEscrow(); const launcherBalance = await token.balanceOf(launcherAddress); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); await expect(escrow.connect(admin).requestCancellation()).to.emit( escrow, 'CancellationRequested' ); + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); expect(await escrow.status()).to.equal(Status.Cancelled); expect(await token.balanceOf(escrow.getAddress())).to.equal(0); expect(await token.balanceOf(launcherAddress)).to.equal( - launcherBalance + - FIXTURE_FUND_AMOUNT - - calculateOracleFee(FIXTURE_FUND_AMOUNT) * 3n + launcherBalance + FIXTURE_FUND_AMOUNT ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index]).to.equal(initialBalance); + }); }); it('Admin: cancels escrow succesfully when escrow has no funds but status is Launched', async function () { @@ -1560,43 +1639,103 @@ describe('Escrow', function () { it('Reputation oracle: completes the escrow successfully without payouts', async function () { const initialLauncherBalance = await token.balanceOf(launcherAddress); const initialEscrowBalance = await token.balanceOf(escrow.getAddress()); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); await storeResults(FIXTURE_URL, FIXTURE_HASH, 0n); - await expect(escrow.connect(reputationOracle).complete()).to.emit( - escrow, - 'Completed' - ); + await expect(escrow.connect(reputationOracle).complete()) + .to.emit(escrow, 'OracleFeeTransfer') + .withArgs( + [ + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + ], + [ + calculateOracleFee(initialEscrowBalance), + calculateOracleFee(initialEscrowBalance), + calculateOracleFee(initialEscrowBalance), + ] + ) + .to.emit(escrow, 'Completed'); expect(await escrow.status()).to.equal(Status.Complete); expect(await escrow.remainingFunds()).to.equal('0'); const finalLauncherBalance = await token.balanceOf(launcherAddress); + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); expect(finalLauncherBalance - initialLauncherBalance).to.equal( initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index] - initialBalance).to.equal( + calculateOracleFee(initialEscrowBalance) + ); + }); }); it('Admin: completes the escrow successfully without payouts', async function () { const initialLauncherBalance = await token.balanceOf(launcherAddress); const initialEscrowBalance = await token.balanceOf(escrow.getAddress()); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); await storeResults(FIXTURE_URL, FIXTURE_HASH, 0n); - await expect(escrow.connect(admin).complete()).to.emit( - escrow, - 'Completed' - ); + await expect(escrow.connect(admin).complete()) + .to.emit(escrow, 'OracleFeeTransfer') + .withArgs( + [ + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + ], + [ + calculateOracleFee(initialEscrowBalance), + calculateOracleFee(initialEscrowBalance), + calculateOracleFee(initialEscrowBalance), + ] + ) + .to.emit(escrow, 'Completed'); expect(await escrow.status()).to.equal(Status.Complete); expect(await escrow.remainingFunds()).to.equal('0'); const finalLauncherBalance = await token.balanceOf(launcherAddress); + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); expect(finalLauncherBalance - initialLauncherBalance).to.equal( initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index] - initialBalance).to.equal( + calculateOracleFee(initialEscrowBalance) + ); + }); }); }); @@ -1630,112 +1769,55 @@ describe('Escrow', function () { escrow.connect(reputationOracle).cancel() ).to.be.revertedWith('Invalid status'); }); - }); - describe('Succeeds', async function () { - beforeEach(async () => { + it('reverts when escrow has reserved funds', async function () { + const workerFunds = await escrow.remainingFunds(); + await storeResults(FIXTURE_URL, FIXTURE_HASH, workerFunds); await escrow.connect(launcher).requestCancellation(); - }); - - it('Reputation oracle: cancels the escrow succesfully', async () => { - const initialLauncherBalance = await token.balanceOf(launcherAddress); - const initialEscrowBalance = await token.balanceOf( - escrow.getAddress() - ); - await expect(escrow.connect(reputationOracle).cancel()) - .to.emit(escrow, 'CancellationRefund') - .withArgs( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - ) - .to.emit(escrow, 'Cancelled'); - - expect(await escrow.status()).to.equal(Status.Cancelled); - - expect(await escrow.remainingFunds()).to.equal('0'); - - const finalLauncherBalance = await token.balanceOf(launcherAddress); - - expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n - ); + await expect( + escrow.connect(reputationOracle).cancel() + ).to.be.revertedWith('Reserved funds'); }); - it('Admin: cancels the escrow succesfully', async () => { - const initialLauncherBalance = await token.balanceOf(launcherAddress); - const initialEscrowBalance = await token.balanceOf( - escrow.getAddress() - ); - - await expect(escrow.connect(admin).cancel()) - .to.emit(escrow, 'CancellationRefund') - .withArgs( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - ) - .to.emit(escrow, 'Cancelled'); - - expect(await escrow.status()).to.equal(Status.Cancelled); - - expect(await escrow.remainingFunds()).to.equal('0'); - - const finalLauncherBalance = await token.balanceOf(launcherAddress); + it('reverts when escrow has reserved funds after a partial payout', async function () { + const workerFunds = await escrow.remainingFunds(); + const payoutAmount = workerFunds / 2n; + await storeResults(FIXTURE_URL, FIXTURE_HASH, workerFunds); + await escrow.connect(launcher).requestCancellation(); + await escrow + .connect(admin) + [ + 'bulkPayOut(address[],uint256[],string,string,string,bool)' + ]([externalAddress], [payoutAmount], FIXTURE_URL, FIXTURE_HASH, '000', false); - expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n + await expect(escrow.connect(admin).cancel()).to.be.revertedWith( + 'Reserved funds' ); }); + }); - it('Reputation oracle: cancels the escrow succesfully after storeResults', async () => { - const initialLauncherBalance = await token.balanceOf(launcherAddress); - const initialEscrowBalance = await token.balanceOf( - escrow.getAddress() - ); - - await storeResults( - FIXTURE_URL, - FIXTURE_HASH, - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n - ); - - await expect(escrow.connect(reputationOracle).cancel()) - .to.emit(escrow, 'CancellationRefund') - .withArgs( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - ) - .to.emit(escrow, 'Cancelled'); - - expect(await escrow.status()).to.equal(Status.Cancelled); - - expect(await escrow.remainingFunds()).to.equal('0'); - - const finalLauncherBalance = await token.balanceOf(launcherAddress); - - expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n - ); + describe('Succeeds', async function () { + beforeEach(async () => { + await escrow.connect(launcher).requestCancellation(); }); - it('Admin: cancels the escrow succesfully after storeResults', async () => { + it('Reputation oracle: cancels the escrow succesfully', async () => { const initialLauncherBalance = await token.balanceOf(launcherAddress); const initialEscrowBalance = await token.balanceOf( escrow.getAddress() ); - - await storeResults( - FIXTURE_URL, - FIXTURE_HASH, - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) ); - await expect(escrow.connect(admin).cancel()) + await expect(escrow.connect(reputationOracle).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - ) + .withArgs(initialEscrowBalance) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1745,73 +1827,36 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n - ); - }); - - it('Reputation oracle: cancels the escrow succesfully after payouts', async () => { - const initialLauncherBalance = await token.balanceOf(launcherAddress); - const initialEscrowBalance = await token.balanceOf( - escrow.getAddress() - ); - - await storeResults( - FIXTURE_URL, - FIXTURE_HASH, - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n + initialEscrowBalance ); - await escrow - .connect(admin) + const finalOracleBalances = await Promise.all( [ - 'bulkPayOut(address[],uint256[],string,string,string,bool)' - ]([externalAddress], [initialEscrowBalance / 2n], FIXTURE_URL, FIXTURE_HASH, '000', false); - - await expect(escrow.connect(reputationOracle).cancel()) - .to.emit(escrow, 'CancellationRefund') - .withArgs( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - - initialEscrowBalance / 2n - ) - .to.emit(escrow, 'Cancelled'); - - expect(await escrow.status()).to.equal(Status.Cancelled); - - expect(await escrow.remainingFunds()).to.equal('0'); - - const finalLauncherBalance = await token.balanceOf(launcherAddress); - - expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - - initialEscrowBalance / 2n + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index]).to.equal(initialBalance); + }); }); - it('Admin: cancels the escrow succesfully after payouts', async () => { + it('Admin: cancels the escrow succesfully', async () => { const initialLauncherBalance = await token.balanceOf(launcherAddress); const initialEscrowBalance = await token.balanceOf( escrow.getAddress() ); - - await storeResults( - FIXTURE_URL, - FIXTURE_HASH, - initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n - ); - await escrow - .connect(admin) + const initialOracleBalances = await Promise.all( [ - 'bulkPayOut(address[],uint256[],string,string,string,bool)' - ]([externalAddress], [initialEscrowBalance / 2n], FIXTURE_URL, FIXTURE_HASH, '000', false); + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); await expect(escrow.connect(admin).cancel()) .to.emit(escrow, 'CancellationRefund') - .withArgs( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - - initialEscrowBalance / 2n - ) + .withArgs(initialEscrowBalance) .to.emit(escrow, 'Cancelled'); expect(await escrow.status()).to.equal(Status.Cancelled); @@ -1821,10 +1866,18 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - - calculateOracleFee(initialEscrowBalance) * 3n - - initialEscrowBalance / 2n + initialEscrowBalance + ); + const finalOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index]).to.equal(initialBalance); + }); }); }); });