Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/puny-horses-rhyme.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand All @@ -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`);
Expand Down Expand Up @@ -131,6 +151,7 @@ describe('JobService', () => {
}).compile();

jobService = moduleRef.get<JobService>(JobService);
web3ConfigService = moduleRef.get<Web3ConfigService>(Web3ConfigService);
});

describe('processJobSolution', () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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()
Expand Down Expand Up @@ -462,13 +511,25 @@ describe('JobService', () => {
};

const result = await jobService.processJobSolution(jobSolution);
const expectedAmountToReserve = calculateAmountToReserve(
fundAmount,
manifest.submissionsRequired,
oracleFees,
);

const expectedBody = {
chain_id: jobSolution.chainId,
escrow_address: jobSolution.escrowAddress,
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -319,4 +315,30 @@ export class JobService {
return 'The requested job is canceled.';
}
}

private async getNetFundAmount(
escrowClient: EscrowClient,
chainId: number,
escrowAddress: string,
): Promise<bigint> {
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,
);
}
}
Loading
Loading