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. diff --git a/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts b/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts index 37ada53a31..f97bc989fe 100644 --- a/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts +++ b/packages/apps/fortune/recording-oracle/src/common/constants/errors.ts @@ -10,6 +10,7 @@ export enum ErrorJob { SolutionAlreadyExists = 'Solution already exists', AllSolutionsHaveAlreadyBeenSent = 'All solutions have already been sent', ManifestNotFound = 'Manifest not found', + NotFound = 'Job not found', } /** 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 b1409a1efa..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 @@ -4,11 +4,13 @@ import { EncryptionUtils, EscrowClient, EscrowStatus, + EscrowUtils, KVStoreUtils, } from '@human-protocol/sdk'; 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, @@ -74,9 +76,24 @@ jest.mock('@human-protocol/sdk', () => ({ }, })); +const calculateAmountToReserve = ( + fundAmount: bigint, + submissionsRequired: number, + oracleFees: number[], +): bigint => { + const netFundAmount = oracleFees.reduce( + (netAmount, fee) => netAmount - (fundAmount * BigInt(fee)) / 100n, + fundAmount, + ); + + return netFundAmount / BigInt(submissionsRequired); +}; + describe('JobService', () => { let jobService: JobService; const downloadFileFromUrlMock = jest.mocked(downloadFileFromUrl); + const mockedEscrowUtils = jest.mocked(EscrowUtils); + let web3ConfigService: Web3ConfigService; jest .spyOn(Web3ConfigService.prototype, 'privateKey', 'get') @@ -98,7 +115,10 @@ describe('JobService', () => { { provide: ConfigService, useValue: { - get: jest.fn((key: string) => mockConfig[key]), + get: jest.fn( + (key: string, defaultValue?: unknown) => + mockConfig[key] ?? defaultValue, + ), getOrThrow: jest.fn((key: string) => { if (!mockConfig[key]) { throw new Error(`Configuration key "${key}" does not exist`); @@ -131,6 +151,7 @@ describe('JobService', () => { }).compile(); jobService = moduleRef.get(JobService); + web3ConfigService = moduleRef.get(Web3ConfigService); }); describe('processJobSolution', () => { @@ -357,15 +378,22 @@ describe('JobService', () => { }); it('should return solution are recorded when one solution is sent', async () => { + const fundAmount = ethers.parseEther('10'); + const oracleFees = [2, 3, 5]; const escrowClient = { getRecordingOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getReputationOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), getStatus: jest.fn().mockResolvedValue(EscrowStatus.Pending), getManifest: jest.fn().mockResolvedValue('http://example.com/manifest'), getIntermediateResultsUrl: jest.fn().mockResolvedValue(''), storeResults: jest.fn().mockResolvedValue(true), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + totalFundedAmount: fundAmount, + recordingOracleFee: oracleFees[0], + reputationOracleFee: oracleFees[1], + exchangeOracleFee: oracleFees[2], + } as any); const manifest: IManifest = { submissionsRequired: 3, @@ -404,11 +432,26 @@ describe('JobService', () => { }; const result = await jobService.processJobSolution(jobSolution); + const expectedAmountToReserve = calculateAmountToReserve( + fundAmount, + manifest.submissionsRequired, + oracleFees, + ); + expect(result).toEqual('Solutions recorded.'); + expect(escrowClient.storeResults).toHaveBeenCalledWith( + jobSolution.escrowAddress, + expect.any(String), + expect.any(String), + expectedAmountToReserve, + { timeoutMs: web3ConfigService.txTimeoutMs }, + ); expect(httpServicePostMock).not.toHaveBeenCalled(); }); it('should call send webhook method when all solutions are recorded', async () => { + const fundAmount = ethers.parseEther('10'); + const oracleFees = [4, 6, 10]; const escrowClient = { getRecordingOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), getReputationOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), @@ -420,6 +463,12 @@ describe('JobService', () => { storeResults: jest.fn().mockResolvedValue(true), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + totalFundedAmount: fundAmount, + recordingOracleFee: oracleFees[0], + reputationOracleFee: oracleFees[1], + exchangeOracleFee: oracleFees[2], + } as any); KVStoreUtils.get = jest .fn() @@ -462,6 +511,11 @@ describe('JobService', () => { }; const result = await jobService.processJobSolution(jobSolution); + const expectedAmountToReserve = calculateAmountToReserve( + fundAmount, + manifest.submissionsRequired, + oracleFees, + ); const expectedBody = { chain_id: jobSolution.chainId, @@ -469,6 +523,13 @@ describe('JobService', () => { event_type: EventType.JOB_COMPLETED, }; expect(result).toEqual('The requested job is completed.'); + expect(escrowClient.storeResults).toHaveBeenCalledWith( + jobSolution.escrowAddress, + expect.any(String), + expect.any(String), + expectedAmountToReserve, + { timeoutMs: web3ConfigService.txTimeoutMs }, + ); expect(httpServicePostMock).toHaveBeenCalledWith( MOCK_REPUTATION_ORACLE_WEBHOOK_URL, expectedBody, @@ -496,6 +557,9 @@ describe('JobService', () => { storeResults: jest.fn().mockResolvedValue(true), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); + KVStoreUtils.get = jest + .fn() + .mockResolvedValue(MOCK_REPUTATION_ORACLE_WEBHOOK_URL); const manifest: IManifest = { submissionsRequired: 4, @@ -572,10 +636,11 @@ describe('JobService', () => { }); it('should call exchange oracle endpoint when solution is wrong', async () => { + const fundAmount = ethers.parseEther('10'); + const oracleFees = [2, 3, 5]; const escrowClient = { getRecordingOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), getExchangeOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getReputationOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), getStatus: jest.fn().mockResolvedValue(EscrowStatus.Pending), getManifest: jest.fn().mockResolvedValue('http://example.com/manifest'), getIntermediateResultsUrl: jest @@ -584,6 +649,12 @@ describe('JobService', () => { storeResults: jest.fn().mockResolvedValue(true), }; (EscrowClient.build as jest.Mock).mockResolvedValue(escrowClient); + mockedEscrowUtils.getEscrow.mockResolvedValueOnce({ + totalFundedAmount: fundAmount, + recordingOracleFee: oracleFees[0], + reputationOracleFee: oracleFees[1], + exchangeOracleFee: oracleFees[2], + } as any); KVStoreUtils.get = jest .fn() .mockResolvedValue(MOCK_EXCHANGE_ORACLE_WEBHOOK_URL); @@ -640,6 +711,13 @@ describe('JobService', () => { }, }; expect(result).toEqual('Solutions recorded.'); + expect(escrowClient.storeResults).toHaveBeenCalledWith( + jobSolution.escrowAddress, + expect.any(String), + expect.any(String), + 0n, + { timeoutMs: web3ConfigService.txTimeoutMs }, + ); expect(httpServicePostMock).toHaveBeenCalledWith( MOCK_EXCHANGE_ORACLE_WEBHOOK_URL, expectedBody, @@ -655,7 +733,6 @@ describe('JobService', () => { const escrowClient = { getRecordingOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), getExchangeOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), - getReputationOracleAddress: jest.fn().mockResolvedValue(MOCK_ADDRESS), getStatus: jest.fn().mockResolvedValue(EscrowStatus.Pending), getManifest: jest.fn().mockResolvedValue('http://example.com/manifest'), getIntermediateResultsUrl: 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 5d348d5057..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 @@ -186,16 +186,12 @@ export class JobService { s.solution === lastExchangeSolution.solution, ); - const escrow = await EscrowUtils.getEscrow( + const netFundAmount = await this.getNetFundAmount( + escrowClient, webhook.chainId, webhook.escrowAddress, ); - if (!escrow) { - throw new ValidationError('Escrow not found'); - } - - const amountToReserve = - escrow.totalFundedAmount / BigInt(submissionsRequired); + const amountToReserve = netFundAmount / BigInt(submissionsRequired); await escrowClient.storeResults( webhook.escrowAddress, @@ -319,4 +315,30 @@ export class JobService { return 'The requested job is canceled.'; } } + + private async getNetFundAmount( + escrowClient: EscrowClient, + chainId: number, + escrowAddress: string, + ): Promise { + const escrow = await EscrowUtils.getEscrow(chainId, escrowAddress); + if (!escrow) { + this.logger.error(ErrorJob.NotFound, { + chainId, + escrowAddress, + }); + throw new ConflictError(ErrorJob.NotFound); + } + const oracleFees = [ + escrow.recordingOracleFee, + escrow.reputationOracleFee, + escrow.exchangeOracleFee, + ]; + + return oracleFees.reduce( + (netFundAmount, fee) => + netFundAmount - (escrow.totalFundedAmount * BigInt(fee || 1)) / 100n, + escrow.totalFundedAmount, + ); + } } diff --git a/packages/core/contracts/Escrow.sol b/packages/core/contracts/Escrow.sol index d4ab130a6f..4e2e223240 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); } @@ -286,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(); } @@ -310,16 +324,61 @@ 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; + uint256 _totalOracleFee = _reputationOracleFee + + _recordingOracleFee + + _exchangeOracleFee; + + IERC20 tokenContract = IERC20(token); + + fundAmount = 0; + remainingFunds = 0; + reservedFunds = 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 (_remaining > 0) { - IERC20 tokenContract = IERC20(token); - tokenContract.safeTransfer(launcher, _remaining); + 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 +442,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(); } } } @@ -443,79 +487,41 @@ 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'); - require(_amounts.length > 0, 'Empty amounts'); - require(_recipients.length <= BULK_MAX_COUNT, 'Too many recipients'); + uint256 length = _amounts.length; + require(_recipients.length == length, 'Length mismatch'); + 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 +530,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..3cd488027e 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,126 @@ 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('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(); + 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 +580,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 +630,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'); @@ -546,21 +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 ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index]).to.equal(initialBalance); + }); }); it('Admin: requests escrow cancellation succesfully', async () => { @@ -583,16 +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 ); + 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 () { @@ -783,7 +942,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 +1073,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 +1087,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 +1104,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 +1130,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 +1157,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 +1212,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 +1234,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 +1276,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 +1301,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 +1316,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 +1334,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 +1373,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 +1428,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 +1450,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 +1492,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 +1556,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 +1582,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,50 +1630,112 @@ describe('Escrow', function () { const finalLauncherBalance = await token.balanceOf(launcherAddress); expect(finalLauncherBalance - initialLauncherBalance).to.equal( - initialEscrowBalance - amounts[0] + initialEscrowBalance - + calculateOracleFee(initialEscrowBalance) * 3n - + amounts[0] ); }); 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 + 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 + initialEscrowBalance - calculateOracleFee(initialEscrowBalance) * 3n ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index] - initialBalance).to.equal( + calculateOracleFee(initialEscrowBalance) + ); + }); }); }); @@ -1543,64 +1769,51 @@ 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) - .to.emit(escrow, 'Cancelled'); - - expect(await escrow.status()).to.equal(Status.Cancelled); - expect(await escrow.remainingFunds()).to.equal('0'); + await expect( + escrow.connect(reputationOracle).cancel() + ).to.be.revertedWith('Reserved funds'); + }); - 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 + await expect(escrow.connect(admin).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) - .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 - ); + describe('Succeeds', async function () { + beforeEach(async () => { + await escrow.connect(launcher).requestCancellation(); }); - it('Reputation oracle: 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); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); await expect(escrow.connect(reputationOracle).cancel()) .to.emit(escrow, 'CancellationRefund') @@ -1616,15 +1829,30 @@ describe('Escrow', function () { expect(finalLauncherBalance - initialLauncherBalance).to.equal( 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); + }); }); - it('Admin: cancels the escrow succesfully after storeResults', 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); + const initialOracleBalances = await Promise.all( + [ + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) + ); await expect(escrow.connect(admin).cancel()) .to.emit(escrow, 'CancellationRefund') @@ -1640,64 +1868,16 @@ describe('Escrow', function () { expect(finalLauncherBalance - initialLauncherBalance).to.equal( initialEscrowBalance ); - }); - - 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); - 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 / 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 / 2n - ); - }); - - it('Admin: 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); - await escrow - .connect(admin) - [ - 'bulkPayOut(address[],uint256[],string,string,string,bool)' - ]([externalAddress], [initialEscrowBalance / 2n], FIXTURE_URL, FIXTURE_HASH, '000', false); - - await expect(escrow.connect(admin).cancel()) - .to.emit(escrow, 'CancellationRefund') - .withArgs(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 / 2n + recordingOracleAddress, + reputationOracleAddress, + exchangeOracleAddress, + ].map(async (oracle) => token.balanceOf(oracle)) ); + initialOracleBalances.forEach((initialBalance, index) => { + expect(finalOracleBalances[index]).to.equal(initialBalance); + }); }); }); }); 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..c0610ebfbe 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, @@ -823,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); @@ -842,3 +844,66 @@ 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; + + // 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]; + + if (amount.equals(ZERO_BI)) { + continue; + } + + event.logIndex = originalLogIndex.plus(BigInt.fromI32(10000 + 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; + 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..b038ff3eda 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() + 10001) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + const firstTransferTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 10000) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + const skippedTransferTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 10002) + .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; +}