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/.openzeppelin/bsc-testnet.json b/packages/core/.openzeppelin/bsc-testnet.json index ea249118dd..3a06d5699d 100644 --- a/packages/core/.openzeppelin/bsc-testnet.json +++ b/packages/core/.openzeppelin/bsc-testnet.json @@ -3278,6 +3278,171 @@ }, "namespaces": {} } + }, + "41cc5faa75bfaae7eb20846b731a4f2fc143876055c1e5c970290ba7e14e853c": { + "address": "0x83C7BdE64C8B3CFAE7c6322245EcaC997CDc7205", + "txHash": "0x7b31d85da370efa4637439c10414fd221d049cd0f06622b323132ad66367d91e", + "layout": { + "solcVersion": "0.8.23", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:169" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:111" + }, + { + "label": "counter", + "offset": 0, + "slot": "201", + "type": "t_uint256", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:17" + }, + { + "label": "escrowCounters", + "offset": 0, + "slot": "202", + "type": "t_mapping(t_address,t_uint256)", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:18" + }, + { + "label": "lastEscrow", + "offset": 0, + "slot": "203", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:19" + }, + { + "label": "staking", + "offset": 0, + "slot": "204", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:20" + }, + { + "label": "minimumStake", + "offset": 0, + "slot": "205", + "type": "t_uint256", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:21" + }, + { + "label": "admin", + "offset": 0, + "slot": "206", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:22" + }, + { + "label": "kvstore", + "offset": 0, + "slot": "207", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:23" + }, + { + "label": "__gap", + "offset": 0, + "slot": "208", + "type": "t_array(t_uint256)43_storage", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:195" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)43_storage": { + "label": "uint256[43]", + "numberOfBytes": "1376" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_mapping(t_address,t_uint256)": { + "label": "mapping(address => uint256)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } } } } diff --git a/packages/core/.openzeppelin/sepolia.json b/packages/core/.openzeppelin/sepolia.json index d34290bbf8..1d1b47d9c0 100644 --- a/packages/core/.openzeppelin/sepolia.json +++ b/packages/core/.openzeppelin/sepolia.json @@ -4185,6 +4185,171 @@ }, "namespaces": {} } + }, + "41cc5faa75bfaae7eb20846b731a4f2fc143876055c1e5c970290ba7e14e853c": { + "address": "0x575A360e6Eaf6262F66A8AE55BB7a651E3a09671", + "txHash": "0xc512fe65eeee11281bc20c1255b1b34e934cd4bdd3772ed122f234d55ef5f400", + "layout": { + "solcVersion": "0.8.23", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:169" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:111" + }, + { + "label": "counter", + "offset": 0, + "slot": "201", + "type": "t_uint256", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:17" + }, + { + "label": "escrowCounters", + "offset": 0, + "slot": "202", + "type": "t_mapping(t_address,t_uint256)", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:18" + }, + { + "label": "lastEscrow", + "offset": 0, + "slot": "203", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:19" + }, + { + "label": "staking", + "offset": 0, + "slot": "204", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:20" + }, + { + "label": "minimumStake", + "offset": 0, + "slot": "205", + "type": "t_uint256", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:21" + }, + { + "label": "admin", + "offset": 0, + "slot": "206", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:22" + }, + { + "label": "kvstore", + "offset": 0, + "slot": "207", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:23" + }, + { + "label": "__gap", + "offset": 0, + "slot": "208", + "type": "t_array(t_uint256)43_storage", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:195" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)43_storage": { + "label": "uint256[43]", + "numberOfBytes": "1376" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_mapping(t_address,t_uint256)": { + "label": "mapping(address => uint256)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } } } } diff --git a/packages/core/.openzeppelin/unknown-80002.json b/packages/core/.openzeppelin/unknown-80002.json index b7209eab96..01d1d1d0d0 100644 --- a/packages/core/.openzeppelin/unknown-80002.json +++ b/packages/core/.openzeppelin/unknown-80002.json @@ -2287,6 +2287,171 @@ }, "namespaces": {} } + }, + "41cc5faa75bfaae7eb20846b731a4f2fc143876055c1e5c970290ba7e14e853c": { + "address": "0x5987A5558d961ee674efe4A8c8eB7B1b5495D3bf", + "txHash": "0x8a0bc34c96b6c43183444baca849d6f3e8578a0b641bde397003479906dffe8c", + "layout": { + "solcVersion": "0.8.23", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:169" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:111" + }, + { + "label": "counter", + "offset": 0, + "slot": "201", + "type": "t_uint256", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:17" + }, + { + "label": "escrowCounters", + "offset": 0, + "slot": "202", + "type": "t_mapping(t_address,t_uint256)", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:18" + }, + { + "label": "lastEscrow", + "offset": 0, + "slot": "203", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:19" + }, + { + "label": "staking", + "offset": 0, + "slot": "204", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:20" + }, + { + "label": "minimumStake", + "offset": 0, + "slot": "205", + "type": "t_uint256", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:21" + }, + { + "label": "admin", + "offset": 0, + "slot": "206", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:22" + }, + { + "label": "kvstore", + "offset": 0, + "slot": "207", + "type": "t_address", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:23" + }, + { + "label": "__gap", + "offset": 0, + "slot": "208", + "type": "t_array(t_uint256)43_storage", + "contract": "EscrowFactory", + "src": "contracts/EscrowFactory.sol:195" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)43_storage": { + "label": "uint256[43]", + "numberOfBytes": "1376" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_mapping(t_address,t_uint256)": { + "label": "mapping(address => uint256)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } } } } diff --git a/packages/core/contracts/Escrow.sol b/packages/core/contracts/Escrow.sol index d4ab130a6f..10f651b998 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) { @@ -236,7 +245,8 @@ contract Escrow is IEscrow, ReentrancyGuard { nonReentrant { require( - remainingFunds != 0 || status == EscrowStatuses.Launched, + status != EscrowStatuses.ToCancel && + (remainingFunds != 0 || status == EscrowStatuses.Launched), 'Invalid status' ); @@ -269,8 +279,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 +300,7 @@ contract Escrow is IEscrow, ReentrancyGuard { */ function cancel() external override notExpired adminOrReputationOracle { require(status == EscrowStatuses.ToCancel, 'Invalid status'); + require(reservedFunds == 0, 'Reserved funds'); _finalize(); } @@ -309,20 +324,65 @@ contract Escrow is IEscrow, ReentrancyGuard { * and updating the status to Complete or Cancelled. */ function _finalize() private { - EscrowStatuses _status = status; - uint256 _remaining = remainingFunds; - - if (_remaining > 0) { - IERC20 tokenContract = IERC20(token); - tokenContract.safeTransfer(launcher, _remaining); - if (_status == EscrowStatuses.ToCancel) { - emit CancellationRefund(_remaining); + bool isCancellation = status == EscrowStatuses.ToCancel; + 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 (_remainingFunds > 0) { + tokenContract.safeTransfer(launcher, _remainingFunds); + if (isCancellation) { + emit CancellationRefund(_remainingFunds); } - remainingFunds = 0; - reservedFunds = 0; } - if (_status == EscrowStatuses.ToCancel) { + if (isCancellation) { status = EscrowStatuses.Cancelled; emit Cancelled(); } else { @@ -376,30 +436,17 @@ contract Escrow is IEscrow, ReentrancyGuard { emit IntermediateStorage(_url, _hash); if (status == EscrowStatuses.ToCancel) { + if (_fundsToReserve == 0) { + _finalize(); + return; + } + uint256 unreservedFunds = remainingFunds - reservedFunds; if (unreservedFunds > 0) { IERC20(token).safeTransfer(launcher, unreservedFunds); emit CancellationRefund(unreservedFunds); 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; - } } } @@ -443,79 +490,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 +533,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..b0ad39a294 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,128 @@ 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, 'CancellationRefund') + .withArgs(initialEscrowBalance) + .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 +582,41 @@ describe('Escrow', function () { .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 + ); + }); }); }); }); @@ -517,13 +645,21 @@ 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'); + }); + + it('reverts when cancellation has already been requested', async function () { + await escrow.connect(launcher).requestCancellation(); + await expect( escrow.connect(launcher).requestCancellation() ).to.be.revertedWith('Invalid status'); @@ -546,21 +682,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 +748,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 +965,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 +1096,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 +1110,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 +1127,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 +1153,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 +1180,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 +1235,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 +1257,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 ); }); @@ -1099,7 +1279,15 @@ describe('Escrow', function () { await escrow.connect(launcher).requestCancellation(); - await storeResults(FIXTURE_URL, FIXTURE_HASH, totalAmount); + const workerFunds = await escrow.remainingFunds(); + const storeResultsTx = await storeResults( + FIXTURE_URL, + FIXTURE_HASH, + totalAmount + ); + await expect(storeResultsTx) + .to.emit(escrow, 'CancellationRefund') + .withArgs(workerFunds - totalAmount); await expect( escrow .connect(reputationOracle) @@ -1119,10 +1307,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 +1332,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 +1347,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 +1365,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 +1404,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 +1459,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 +1481,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 +1523,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 +1587,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 +1613,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 +1661,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 +1800,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 +1860,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 +1899,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) - [ - '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) + 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(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/constants.py b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/constants.py index 5384fbaffd..6ee2c262f4 100644 --- a/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/constants.py +++ b/packages/sdk/python/human-protocol-sdk/human_protocol_sdk/constants.py @@ -75,7 +75,7 @@ class OperatorCategory(Enum): "https://api.studio.thegraph.com/query/74256/human-sepolia/version/latest" ), "subgraph_url_api_key": ( - "https://gateway.thegraph.com/api/deployments/id/QmdsJaanpNKXd3ov2Cp6vmpu8uvA69iBfwxswFMu4ZcmNJ" + "https://gateway.thegraph.com/api/deployments/id/QmSTMqRb3fYLikBMWkbQn6FErxtX6NqPiTsdU8WTL8BvDd" ), "hmt_address": "0x792abbcC99c01dbDec49c9fa9A828a186Da45C33", "factory_address": "0x5987A5558d961ee674efe4A8c8eB7B1b5495D3bf", @@ -111,7 +111,7 @@ class OperatorCategory(Enum): "https://api.studio.thegraph.com/query/74256/human-bsc-testnet/version/latest" ), "subgraph_url_api_key": ( - "https://gateway.thegraph.com/api/deployments/id/QmNNb3ZQdJiziXksfUNiKYKio7A9u8eMTJYB8jpWQza2uv" + "https://gateway.thegraph.com/api/deployments/id/QmSXHJtARcEauWWh29nVYLixWtxMytMWfoRrbrsPpya7PC" ), "hmt_address": "0xE3D74BBFa45B4bCa69FF28891fBE392f4B4d4e4d", "factory_address": "0x2bfA592DBDaF434DDcbb893B1916120d181DAD18", @@ -151,7 +151,7 @@ class OperatorCategory(Enum): "https://api.studio.thegraph.com/query/74256/human-amoy/version/latest" ), "subgraph_url_api_key": ( - "https://gateway.thegraph.com/api/deployments/id/QmW3KQfZu1sGz1CedPKC9okCuMFvi8EbrjKncoHn3UPFry" + "https://gateway.thegraph.com/api/deployments/id/QmXndE4LdPAtrh237cPYAr1z8SNaXCqzyzX1XuVPZR3wBQ" ), "hmt_address": "0x792abbcC99c01dbDec49c9fa9A828a186Da45C33", "factory_address": "0xAFf5a986A530ff839d49325A5dF69F96627E8D29", 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/constants.ts b/packages/sdk/typescript/human-protocol-sdk/src/constants.ts index 88971e1d41..7a9b08ded7 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/constants.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/constants.ts @@ -55,7 +55,7 @@ export const NETWORKS: { subgraphUrl: 'https://api.studio.thegraph.com/query/74256/human-sepolia/version/latest', subgraphUrlApiKey: - 'https://gateway.thegraph.com/api/deployments/id/QmdsJaanpNKXd3ov2Cp6vmpu8uvA69iBfwxswFMu4ZcmNJ', + 'https://gateway.thegraph.com/api/deployments/id/QmSTMqRb3fYLikBMWkbQn6FErxtX6NqPiTsdU8WTL8BvDd', oldSubgraphUrl: '', oldFactoryAddress: '', hmtSubgraphUrl: @@ -93,7 +93,7 @@ export const NETWORKS: { subgraphUrl: 'https://api.studio.thegraph.com/query/74256/human-bsc-testnet/version/latest', subgraphUrlApiKey: - 'https://gateway.thegraph.com/api/deployments/id/QmNNb3ZQdJiziXksfUNiKYKio7A9u8eMTJYB8jpWQza2uv', + 'https://gateway.thegraph.com/api/deployments/id/QmSXHJtARcEauWWh29nVYLixWtxMytMWfoRrbrsPpya7PC', oldSubgraphUrl: 'https://api.thegraph.com/subgraphs/name/humanprotocol/bsctest', oldFactoryAddress: '0xaae6a2646c1f88763e62e0cd08ad050ea66ac46f', @@ -133,7 +133,7 @@ export const NETWORKS: { subgraphUrl: 'https://api.studio.thegraph.com/query/74256/human-amoy/version/latest', subgraphUrlApiKey: - 'https://gateway.thegraph.com/api/deployments/id/QmW3KQfZu1sGz1CedPKC9okCuMFvi8EbrjKncoHn3UPFry', + 'https://gateway.thegraph.com/api/deployments/id/QmXndE4LdPAtrh237cPYAr1z8SNaXCqzyzX1XuVPZR3wBQ', oldSubgraphUrl: '', oldFactoryAddress: '', hmtSubgraphUrl: 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..9b5c52d1fb 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, @@ -681,7 +682,9 @@ export function handleCompleted(event: Completed): void { Address.fromBytes(escrowEntity.address) ); if (escrowEntity.balance && escrowEntity.balance.gt(ZERO_BI)) { - const internalTransaction = new InternalTransaction(toEventId(event)); + const internalTransaction = new InternalTransaction( + toEventId(event, 'transfer') + ); internalTransaction.from = escrowEntity.address; internalTransaction.to = escrowEntity.launcher; internalTransaction.value = escrowEntity.balance; @@ -811,25 +814,16 @@ export function handleCancellationRefund(event: CancellationRefund): void { const escrowEntity = Escrow.load(dataSource.address()); if (!escrowEntity) return; - const transaction = createTransaction( + createTransaction( event, - 'cancellationRefund', + 'transfer', event.transaction.from, - Address.fromBytes(escrowEntity.address), - Address.fromBytes(escrowEntity.launcher), + Address.fromBytes(escrowEntity.canceler), + Address.fromBytes(escrowEntity.canceler), Address.fromBytes(escrowEntity.address), event.params.amount, Address.fromBytes(escrowEntity.token) ); - const internalTransaction = new InternalTransaction(toEventId(event)); - internalTransaction.from = escrowEntity.address; - internalTransaction.to = Address.fromBytes(escrowEntity.token); - internalTransaction.receiver = escrowEntity.canceler; - internalTransaction.value = escrowEntity.balance; - internalTransaction.transaction = transaction.id; - internalTransaction.method = 'transfer'; - internalTransaction.token = Address.fromBytes(escrowEntity.token); - internalTransaction.save(); escrowEntity.balance = escrowEntity.balance.minus(event.params.amount); escrowEntity.save(); @@ -842,3 +836,48 @@ 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(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/src/mapping/utils/event.ts b/packages/subgraph/human-protocol/src/mapping/utils/event.ts index e33f662619..ab1495ba29 100644 --- a/packages/subgraph/human-protocol/src/mapping/utils/event.ts +++ b/packages/subgraph/human-protocol/src/mapping/utils/event.ts @@ -1,10 +1,16 @@ import { BigInt, Bytes, ethereum } from '@graphprotocol/graph-ts'; import { ONE_DAY } from './number'; -export function toEventId(event: ethereum.Event): Bytes { - return event.transaction.hash +export function toEventId(event: ethereum.Event, method: string = ''): Bytes { + let id = event.transaction.hash .concatI32(event.logIndex.toI32()) .concatI32(event.block.timestamp.toI32()); + + if (method.length > 0) { + id = id.concat(Bytes.fromUTF8(method)); + } + + return id; } export function toPreviousEventId(event: ethereum.Event): Bytes { diff --git a/packages/subgraph/human-protocol/src/mapping/utils/transaction.ts b/packages/subgraph/human-protocol/src/mapping/utils/transaction.ts index 65eb1ed009..6758db52a7 100644 --- a/packages/subgraph/human-protocol/src/mapping/utils/transaction.ts +++ b/packages/subgraph/human-protocol/src/mapping/utils/transaction.ts @@ -1,4 +1,4 @@ -import { Address, BigInt, ethereum } from '@graphprotocol/graph-ts'; +import { Address, BigInt, Bytes, ethereum } from '@graphprotocol/graph-ts'; import { Transaction, InternalTransaction } from '../../../generated/schema'; import { toEventId, toPreviousEventId } from './event'; @@ -20,6 +20,29 @@ const mainMethods: string[] = [ 'approve', ]; +function createInternalTransaction( + id: Bytes, + transactionId: Bytes, + method: string, + from: Bytes, + to: Bytes, + value: BigInt, + receiver: Bytes | null = null, + escrow: Bytes | null = null, + token: Bytes | null = null +): void { + const internalTransaction = new InternalTransaction(id); + internalTransaction.method = method; + internalTransaction.from = from; + internalTransaction.to = to; + internalTransaction.value = value; + internalTransaction.transaction = transactionId; + internalTransaction.token = token; + internalTransaction.escrow = escrow; + internalTransaction.receiver = receiver; + internalTransaction.save(); +} + export function createTransaction( event: ethereum.Event, method: string, @@ -31,6 +54,17 @@ export function createTransaction( token: Address | null = null ): Transaction { let transaction = Transaction.load(event.transaction.hash); + const transactionTo = Address.fromBytes(event.transaction.to!); + const isMainMethod = mainMethods.includes(method); + // Escrow finalization can emit token transfers before the status event, so + // keep those transfers internal until cancel/complete can claim the tx. + const isEscrowScopedInternal = + escrow !== null && + transactionTo == escrow && + transactionTo != to && + !isMainMethod; + const zeroValue = BigInt.fromI32(0); + if (transaction == null) { transaction = new Transaction(event.transaction.hash); transaction.txHash = event.transaction.hash; @@ -39,54 +73,71 @@ export function createTransaction( transaction.from = from; transaction.to = event.transaction.to!; - if ( - Address.fromBytes(transaction.to) != to && + if (isEscrowScopedInternal) { + transaction.method = 'multimethod'; + transaction.value = zeroValue; + transaction.token = null; + transaction.escrow = escrow; + transaction.receiver = null; + + createInternalTransaction( + toEventId(event, method), + transaction.txHash, + method, + from, + to, + value !== null ? value : zeroValue, + receiver, + escrow, + token + ); + } else if ( + transactionTo != to && (escrow === null || Address.fromBytes(transaction.to) != escrow) && (token === null || Address.fromBytes(transaction.to) != token) ) { transaction.method = 'multimethod'; - transaction.value = BigInt.fromI32(0); + transaction.value = zeroValue; transaction.token = null; transaction.escrow = null; - const internalTransaction = new InternalTransaction(toEventId(event)); - internalTransaction.method = method; - internalTransaction.from = from; - internalTransaction.to = to; - internalTransaction.value = value !== null ? value : BigInt.fromI32(0); - internalTransaction.transaction = transaction.txHash; - internalTransaction.token = token; - internalTransaction.escrow = escrow; - internalTransaction.receiver = receiver; - internalTransaction.save(); + createInternalTransaction( + toEventId(event, method), + transaction.txHash, + method, + from, + to, + value !== null ? value : zeroValue, + receiver, + escrow, + token + ); } else { transaction.to = to; transaction.method = method; - transaction.value = value !== null ? value : BigInt.fromI32(0); + transaction.value = value !== null ? value : zeroValue; transaction.token = token; transaction.escrow = escrow; transaction.receiver = receiver; } transaction.save(); - } else if ( - mainMethods.includes(method) && - Address.fromBytes(transaction.to) == to - ) { + } else if (isMainMethod && Address.fromBytes(transaction.to) == to) { if (mainMethods.includes(transaction.method)) { - const internalTransaction = new InternalTransaction(toEventId(event)); - internalTransaction.method = method; - internalTransaction.from = from; - internalTransaction.to = to; - internalTransaction.value = value !== null ? value : BigInt.fromI32(0); - internalTransaction.transaction = transaction.txHash; - internalTransaction.token = token; - internalTransaction.escrow = escrow; - internalTransaction.receiver = receiver; - internalTransaction.save(); + createInternalTransaction( + toEventId(event, method), + transaction.txHash, + method, + from, + to, + value !== null ? value : zeroValue, + receiver, + escrow, + token + ); } else { transaction.method = method; transaction.from = from; - transaction.value = value !== null ? value : BigInt.fromI32(0); + transaction.value = value !== null ? value : zeroValue; transaction.token = token; transaction.escrow = escrow; transaction.receiver = receiver; @@ -98,31 +149,30 @@ export function createTransaction( method == 'set' && transaction.to == to ) { - const internalTransaction = new InternalTransaction( - toPreviousEventId(event) + createInternalTransaction( + toPreviousEventId(event), + transaction.txHash, + transaction.method, + transaction.from, + Address.fromBytes(transaction.to), + transaction.value ); - internalTransaction.method = transaction.method; - internalTransaction.from = transaction.from; - internalTransaction.to = transaction.to; - internalTransaction.value = transaction.value; - internalTransaction.transaction = transaction.txHash; - internalTransaction.save(); transaction.method = 'setBulk'; transaction.save(); } - const internalTransaction = new InternalTransaction(toEventId(event)); - internalTransaction.method = method; - internalTransaction.from = from; - internalTransaction.to = to; - internalTransaction.value = - value !== null ? value : event.transaction.value; - internalTransaction.transaction = transaction.txHash; - internalTransaction.token = token; - internalTransaction.escrow = escrow; - internalTransaction.receiver = receiver; - internalTransaction.save(); + createInternalTransaction( + toEventId(event, method), + transaction.txHash, + method, + from, + to, + value !== null ? value : event.transaction.value, + receiver, + escrow, + token + ); } return transaction; 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..f83793bf47 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, @@ -1392,7 +1394,7 @@ describe('Escrow', () => { 'Transaction', cancellationRefund.transaction.hash.toHex(), 'method', - 'cancellationRefund' + 'multimethod' ); assert.fieldEquals( 'Transaction', @@ -1403,17 +1405,358 @@ describe('Escrow', () => { assert.fieldEquals( 'Transaction', cancellationRefund.transaction.hash.toHex(), + 'value', + '0' + ); + + const transferId = toEventId(cancellationRefund, 'transfer').toHex(); + assert.fieldEquals('InternalTransaction', transferId, 'method', 'transfer'); + assert.fieldEquals( + 'InternalTransaction', + transferId, + 'escrow', + escrowAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + transferId, 'receiver', - launcherAddress.toHex() + launcherAddressString ); assert.fieldEquals( - 'Transaction', - cancellationRefund.transaction.hash.toHex(), + 'InternalTransaction', + transferId, 'value', amount.toString() ); }); + 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() + 1) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + const firstTransferTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32()) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + const skippedTransferTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 2) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .toHex(); + const firstInternalTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32()) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .concat(Bytes.fromUTF8('transfer')) + .toHex(); + const secondInternalTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 1) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .concat(Bytes.fromUTF8('transfer')) + .toHex(); + const skippedInternalTransactionId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 2) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .concat(Bytes.fromUTF8('transfer')) + .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', + firstInternalTransactionId, + 'method', + 'transfer' + ); + assert.fieldEquals( + 'InternalTransaction', + firstInternalTransactionId, + 'receiver', + reputationOracleAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + firstInternalTransactionId, + 'transaction', + oracleFeeTransfer.transaction.hash.toHex() + ); + assert.fieldEquals( + 'InternalTransaction', + secondInternalTransactionId, + 'receiver', + recordingOracleAddressString + ); + assert.notInStore('InternalTransaction', skippedInternalTransactionId); + assert.notInStore('Transaction', firstTransferTransactionId); + assert.fieldEquals( + 'EventDayData', + Bytes.fromI32(1).toHex(), + 'dailyPayoutCount', + '2' + ); + }); + + test('Should keep oracle fee and cancellation refund transfers internal when cancel follows OracleFeeTransfer', () => { + const escrow = Escrow.load(escrowAddress); + escrow!.balance = BigInt.fromI32(100); + escrow!.token = tokenAddress; + escrow!.launcher = launcherAddress; + escrow!.canceler = launcherAddress; + escrow!.save(); + + const oracleFeeTransfer = createOracleFeeTransferEvent( + escrowAddress, + operatorAddress, + [reputationOracleAddress, recordingOracleAddress, exchangeOracleAddress], + [3, 3, 0], + BigInt.fromI32(86401) + ); + + handleOracleFeeTransfer(oracleFeeTransfer); + + const firstOracleFeeTransferId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32()) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .concat(Bytes.fromUTF8('transfer')) + .toHex(); + const secondOracleFeeTransferId = oracleFeeTransfer.transaction.hash + .concatI32(oracleFeeTransfer.logIndex.toI32() + 1) + .concatI32(oracleFeeTransfer.block.timestamp.toI32()) + .concat(Bytes.fromUTF8('transfer')) + .toHex(); + + const cancellationRefund = createCancellationRefundEvent( + escrowAddress, + operatorAddress, + 94, + BigInt.fromI32(86401) + ); + cancellationRefund.transaction.hash = oracleFeeTransfer.transaction.hash; + cancellationRefund.transaction.to = escrowAddress; + cancellationRefund.block.timestamp = oracleFeeTransfer.block.timestamp; + cancellationRefund.logIndex = oracleFeeTransfer.logIndex.plus( + BigInt.fromI32(1) + ); + + handleCancellationRefund(cancellationRefund); + + const cancelled = createCancelledEvent(operatorAddress); + cancelled.address = escrowAddress; + cancelled.transaction.hash = oracleFeeTransfer.transaction.hash; + cancelled.transaction.to = escrowAddress; + cancelled.block.timestamp = oracleFeeTransfer.block.timestamp; + cancelled.logIndex = oracleFeeTransfer.logIndex.plus(BigInt.fromI32(2)); + + handleCancelled(cancelled); + + const cancellationRefundId = toEventId(cancellationRefund).toHex(); + const cancellationRefundTransferId = toEventId( + cancellationRefund, + 'transfer' + ).toHex(); + + assert.fieldEquals( + 'Transaction', + oracleFeeTransfer.transaction.hash.toHex(), + 'method', + 'cancel' + ); + assert.fieldEquals( + 'Transaction', + oracleFeeTransfer.transaction.hash.toHex(), + 'escrow', + escrowAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + firstOracleFeeTransferId, + 'escrow', + escrowAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + secondOracleFeeTransferId, + 'escrow', + escrowAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + cancellationRefundTransferId, + 'method', + 'transfer' + ); + assert.fieldEquals( + 'InternalTransaction', + cancellationRefundTransferId, + 'escrow', + escrowAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + cancellationRefundTransferId, + 'receiver', + launcherAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + cancellationRefundTransferId, + 'transaction', + oracleFeeTransfer.transaction.hash.toHex() + ); + assert.fieldEquals( + 'CancellationRefundEvent', + cancellationRefundId, + 'amount', + '94' + ); + }); + + test('Should keep complete and refund internal when force-complete follows bulk payout', () => { + const escrow = Escrow.load(escrowAddress); + escrow!.balance = BigInt.fromI32(100); + escrow!.token = tokenAddress; + escrow!.launcher = launcherAddress; + escrow!.save(); + + const bulkTransfer = createBulkTransferV3Event( + operatorAddress, + Bytes.fromUTF8('force-complete-payout'), + [workerAddress], + [49], + false, + 'test.com', + 'test-hash', + BigInt.fromI32(86402) + ); + bulkTransfer.address = escrowAddress; + bulkTransfer.transaction.to = escrowAddress; + + handleBulkTransferV3(bulkTransfer); + + const oracleFeeTransfer = createOracleFeeTransferEvent( + escrowAddress, + operatorAddress, + [reputationOracleAddress, recordingOracleAddress, exchangeOracleAddress], + [3, 3, 0], + BigInt.fromI32(86402) + ); + oracleFeeTransfer.transaction.hash = bulkTransfer.transaction.hash; + oracleFeeTransfer.transaction.to = escrowAddress; + oracleFeeTransfer.logIndex = bulkTransfer.logIndex.plus(BigInt.fromI32(1)); + + handleOracleFeeTransfer(oracleFeeTransfer); + + const completed = createCompletedEvent( + operatorAddress, + BigInt.fromI32(86402) + ); + completed.address = escrowAddress; + completed.transaction.hash = bulkTransfer.transaction.hash; + completed.transaction.to = escrowAddress; + completed.block.timestamp = bulkTransfer.block.timestamp; + completed.logIndex = oracleFeeTransfer.logIndex.plus(BigInt.fromI32(1)); + + handleCompleted(completed); + + const workerTransferId = bulkTransfer.transaction.hash + .concatI32(0) + .concatI32(bulkTransfer.block.timestamp.toI32()) + .toHex(); + const completeInternalId = toEventId(completed, 'complete').toHex(); + const launcherRefundTransferId = toEventId(completed, 'transfer').toHex(); + + assert.fieldEquals( + 'Transaction', + bulkTransfer.transaction.hash.toHex(), + 'method', + 'bulkTransfer' + ); + assert.fieldEquals( + 'InternalTransaction', + workerTransferId, + 'method', + 'transfer' + ); + assert.fieldEquals( + 'InternalTransaction', + completeInternalId, + 'method', + 'complete' + ); + assert.fieldEquals( + 'InternalTransaction', + completeInternalId, + 'escrow', + escrowAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + launcherRefundTransferId, + 'method', + 'transfer' + ); + assert.fieldEquals( + 'InternalTransaction', + launcherRefundTransferId, + 'receiver', + launcherAddressString + ); + assert.fieldEquals( + 'InternalTransaction', + launcherRefundTransferId, + 'escrow', + escrowAddressString + ); + }); + 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; +} diff --git a/packages/subgraph/human-protocol/tests/kvstore/kvstore.test.ts b/packages/subgraph/human-protocol/tests/kvstore/kvstore.test.ts index b94bb82ed8..2bf071dbf2 100644 --- a/packages/subgraph/human-protocol/tests/kvstore/kvstore.test.ts +++ b/packages/subgraph/human-protocol/tests/kvstore/kvstore.test.ts @@ -12,7 +12,7 @@ import { import { Operator } from '../../generated/schema'; import { handleDataSaved } from '../../src/mapping/KVStore'; import { createOrLoadStaker } from '../../src/mapping/Staking'; -import { toEventId } from '../../src/mapping/utils/event'; +import { toPreviousEventId } from '../../src/mapping/utils/event'; import { toBytes } from '../../src/mapping/utils/string'; import { createDataSavedEvent } from './fixtures'; @@ -175,7 +175,7 @@ describe('KVStore', () => { kvStoreAddressString ); - const internalTransactionId = toEventId(data1).toHex(); + const internalTransactionId = toPreviousEventId(data2).toHex(); assert.fieldEquals( 'InternalTransaction', internalTransactionId,