diff --git a/packages/core/contracts/Escrow.sol b/packages/core/contracts/Escrow.sol index bde20acbf7..c6e677d168 100644 --- a/packages/core/contracts/Escrow.sol +++ b/packages/core/contracts/Escrow.sol @@ -150,7 +150,7 @@ contract Escrow is IEscrow, ReentrancyGuard { uint8 _exchangeOracleFeePercentage, string calldata _url, string calldata _hash - ) external override adminOrLauncher notExpired { + ) external override adminLauncherOrFactory notExpired { require(_reputationOracle != address(0), 'Invalid reputation oracle'); require(_recordingOracle != address(0), 'Invalid recording oracle'); require(_exchangeOracle != address(0), 'Invalid exchange oracle'); @@ -495,6 +495,16 @@ contract Escrow is IEscrow, ReentrancyGuard { _; } + modifier adminLauncherOrFactory() { + require( + msg.sender == admin || + msg.sender == launcher || + msg.sender == escrowFactory, + 'Unauthorised' + ); + _; + } + modifier adminOrReputationOracle() { require( msg.sender == admin || msg.sender == reputationOracle, diff --git a/packages/core/contracts/EscrowFactory.sol b/packages/core/contracts/EscrowFactory.sol index ad35103331..81341b07bb 100644 --- a/packages/core/contracts/EscrowFactory.sol +++ b/packages/core/contracts/EscrowFactory.sol @@ -4,7 +4,8 @@ pragma solidity ^0.8.0; import '@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol'; import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; - +import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import './interfaces/IStaking.sol'; import './Escrow.sol'; @@ -20,6 +21,8 @@ contract EscrowFactory is OwnableUpgradeable, UUPSUpgradeable { uint256 public minimumStake; address public admin; + using SafeERC20 for IERC20; + event Launched(address token, address escrow); event LaunchedV2(address token, address escrow, string jobRequesterId); event SetStakingAddress(address indexed stakingAddress); @@ -41,30 +44,93 @@ contract EscrowFactory is OwnableUpgradeable, UUPSUpgradeable { _setEscrowAdmin(msg.sender); } - /** - * @dev Creates a new Escrow contract. - * - * @param token Token address to be associated with the Escrow contract. - * @param jobRequesterId String identifier for the job requester, used for tracking purposes. - * - * @return The address of the newly created Escrow contract. - */ - function createEscrow( - address token, - string memory jobRequesterId - ) external returns (address) { + function _launchEscrow( + address _token, + string calldata _jobRequesterId + ) private { uint256 availableStake = IStaking(staking).getAvailableStake( msg.sender ); require(availableStake >= minimumStake, 'Insufficient stake'); require(admin != address(0), ERROR_ZERO_ADDRESS); - Escrow escrow = new Escrow(token, msg.sender, admin, STANDARD_DURATION); + Escrow escrow = new Escrow( + _token, + msg.sender, + admin, + STANDARD_DURATION + ); counter++; escrowCounters[address(escrow)] = counter; lastEscrow = address(escrow); - emit LaunchedV2(token, lastEscrow, jobRequesterId); + emit LaunchedV2(_token, lastEscrow, _jobRequesterId); + } + + /** + * @dev Creates a new Escrow contract. + * + * @param _token Token address to be associated with the Escrow contract. + * @param _jobRequesterId String identifier for the job requester, used for tracking purposes. + * + * @return The address of the newly created Escrow contract. + */ + function createEscrow( + address _token, + string calldata _jobRequesterId + ) external returns (address) { + _launchEscrow(_token, _jobRequesterId); + return lastEscrow; + } + + /** + * @dev Creates a new Escrow contract and funds it in one transaction. + * Requires the caller to have approved the factory for the token and amount. + * @param _token Token address to be associated with the Escrow contract. + * @param _amount Amount of tokens to fund the Escrow with. + * @param _jobRequesterId String identifier for the job requester, used for tracking purposes. + * @param _reputationOracle Address of the reputation oracle. + * @param _recordingOracle Address of the recording oracle. + * @param _exchangeOracle Address of the exchange oracle. + * @param _reputationOracleFeePercentage Fee percentage for the reputation oracle. + * @param _recordingOracleFeePercentage Fee percentage for the recording oracle. + * @param _exchangeOracleFeePercentage Fee percentage for the exchange oracle. + * @param _url URL for the escrow manifest. + * @param _hash Hash of the escrow manifest. + * @return The address of the newly created Escrow contract. + */ + function createFundAndSetupEscrow( + address _token, + uint256 _amount, + string calldata _jobRequesterId, + address _reputationOracle, + address _recordingOracle, + address _exchangeOracle, + uint8 _reputationOracleFeePercentage, + uint8 _recordingOracleFeePercentage, + uint8 _exchangeOracleFeePercentage, + string calldata _url, + string calldata _hash + ) external returns (address) { + require(_amount > 0, 'Amount is 0'); + + _launchEscrow(_token, _jobRequesterId); + IERC20(_token).safeTransferFrom( + msg.sender, + address(lastEscrow), + _amount + ); + Escrow(lastEscrow).setup( + _reputationOracle, + _recordingOracle, + _exchangeOracle, + _reputationOracleFeePercentage, + _recordingOracleFeePercentage, + _exchangeOracleFeePercentage, + _url, + _hash + ); + return lastEscrow; } diff --git a/packages/core/test/EscrowFactory.ts b/packages/core/test/EscrowFactory.ts index fc4661c168..ab4941afb8 100644 --- a/packages/core/test/EscrowFactory.ts +++ b/packages/core/test/EscrowFactory.ts @@ -5,7 +5,16 @@ import { ethers, upgrades } from 'hardhat'; import { EscrowFactory, HMToken, Staking } from '../typechain-types'; import { faker } from '@faker-js/faker'; -let owner: Signer, launcher: Signer, admin: Signer; +let owner: Signer, + launcher1: Signer, + admin: Signer, + launcher2: Signer, + exchangeOracle: Signer, + recordingOracle: Signer, + reputationOracle: Signer; +let exchangeOracleAddress: string, + recordingOracleAddress: string, + reputationOracleAddress: string; let token: HMToken, escrowFactory: EscrowFactory, staking: Staking; let stakingAddress: string, tokenAddress: string; @@ -24,7 +33,18 @@ async function stake(staker: Signer, amount: bigint = FIXTURE_STAKE_AMOUNT) { describe('EscrowFactory', function () { before(async () => { - [owner, launcher, admin] = await ethers.getSigners(); + [ + owner, + launcher1, + admin, + launcher2, + exchangeOracle, + recordingOracle, + reputationOracle, + ] = await ethers.getSigners(); + exchangeOracleAddress = await exchangeOracle.getAddress(); + recordingOracleAddress = await recordingOracle.getAddress(); + reputationOracleAddress = await reputationOracle.getAddress(); const HMToken = await ethers.getContractFactory( 'contracts/HMToken.sol:HMToken' @@ -39,7 +59,10 @@ describe('EscrowFactory', function () { await token .connect(owner) - .transfer(await launcher.getAddress(), ethers.parseEther('100000')); + .transfer(await launcher1.getAddress(), ethers.parseEther('100000')); + await token + .connect(owner) + .transfer(await launcher2.getAddress(), ethers.parseEther('100000')); const Staking = await ethers.getContractFactory('Staking'); staking = await Staking.deploy( @@ -50,8 +73,6 @@ describe('EscrowFactory', function () { ); stakingAddress = await staking.getAddress(); - await token.connect(launcher).approve(await staking.getAddress(), 1000); - const EscrowFactory = await ethers.getContractFactory( 'contracts/EscrowFactory.sol:EscrowFactory' ); @@ -109,7 +130,7 @@ describe('EscrowFactory', function () { it('reverts when caller is not the owner', async () => { await expect( - escrowFactory.connect(launcher).setStakingAddress(stakingAddress) + escrowFactory.connect(launcher1).setStakingAddress(stakingAddress) ).to.be.revertedWith('Ownable: caller is not the owner'); }); }); @@ -147,7 +168,7 @@ describe('EscrowFactory', function () { it('reverts when caller is not the owner', async () => { await expect( - escrowFactory.connect(launcher).setMinimumStake(0) + escrowFactory.connect(launcher1).setMinimumStake(0) ).to.be.revertedWith('Ownable: caller is not the owner'); }); }); @@ -176,7 +197,7 @@ describe('EscrowFactory', function () { it('reverts when caller is not the owner', async () => { await expect( - escrowFactory.connect(launcher).setAdmin(await admin.getAddress()) + escrowFactory.connect(launcher1).setAdmin(await admin.getAddress()) ).to.be.revertedWith('Ownable: caller is not the owner'); }); }); @@ -197,7 +218,7 @@ describe('EscrowFactory', function () { it('reverts when launcher has insufficient stake', async () => { await expect( escrowFactory - .connect(launcher) + .connect(launcher1) .createEscrow(tokenAddress, FIXTURE_REQUESTER_ID) ).to.be.revertedWith('Insufficient stake'); }); @@ -205,10 +226,10 @@ describe('EscrowFactory', function () { describe('succeeds', () => { it('creates an escrow successfully', async () => { - await stake(launcher); + await stake(launcher1); const tx = await escrowFactory - .connect(launcher) + .connect(launcher1) .createEscrow(tokenAddress, FIXTURE_REQUESTER_ID); await expect(tx) @@ -229,4 +250,137 @@ describe('EscrowFactory', function () { }); }); }); + + describe('createFundAndSetupEscrow()', () => { + const fee = faker.number.int({ min: 1, max: 5 }); + const manifestUrl = faker.internet.url(); + const manifestHash = faker.string.alphanumeric(46); + const fundAmount = ethers.parseEther( + faker.finance.amount({ min: 1, max: 100 }) + ); + describe('reverts', () => { + it('reverts when fund amount is 0', async () => { + await expect( + escrowFactory + .connect(launcher2) + .createFundAndSetupEscrow( + tokenAddress, + 0, + FIXTURE_REQUESTER_ID, + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + fee, + fee, + fee, + manifestUrl, + manifestHash + ) + ).to.be.revertedWith('Amount is 0'); + }); + + it('reverts when launcher has insufficient stake', async () => { + await expect( + escrowFactory + .connect(launcher2) + .createFundAndSetupEscrow( + tokenAddress, + fundAmount, + FIXTURE_REQUESTER_ID, + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + fee, + fee, + fee, + manifestUrl, + manifestHash + ) + ).to.be.revertedWith('Insufficient stake'); + }); + + it('reverts when allowance is too low', async () => { + await stake(launcher2); + await token + .connect(launcher2) + .approve(await escrowFactory.getAddress(), fundAmount / 2n); + + await expect( + escrowFactory + .connect(launcher2) + .createFundAndSetupEscrow( + tokenAddress, + fundAmount, + FIXTURE_REQUESTER_ID, + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + fee, + fee, + fee, + manifestUrl, + manifestHash + ) + ).to.be.revertedWith('Spender allowance too low'); + }); + }); + + describe('succeeds', () => { + it('creates an escrow successfully', async () => { + await stake(launcher2); + + await token + .connect(launcher2) + .approve(await escrowFactory.getAddress(), fundAmount); + + const tx = await escrowFactory + .connect(launcher2) + .createFundAndSetupEscrow( + tokenAddress, + fundAmount, + FIXTURE_REQUESTER_ID, + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress, + fee, + fee, + fee, + manifestUrl, + manifestHash + ); + + const receipt = await tx.wait(); + const event = ( + receipt?.logs?.find(({ topics }) => + topics.includes(ethers.id('LaunchedV2(address,address,string)')) + ) as EventLog + )?.args; + + expect(event).to.not.be.undefined; + const escrowAddress = event[1]; + + const escrow = await ethers.getContractAt( + 'contracts/Escrow.sol:Escrow', + escrowAddress + ); + + await expect(tx) + .to.emit(escrowFactory, 'LaunchedV2') + .withArgs(tokenAddress, escrowAddress, FIXTURE_REQUESTER_ID) + .to.emit(escrow, 'PendingV2') + .withArgs( + manifestUrl, + manifestHash, + reputationOracleAddress, + recordingOracleAddress, + exchangeOracleAddress + ) + .to.emit(escrow, 'Fund') + .withArgs(fundAmount); + + expect(await escrowFactory.hasEscrow(escrowAddress)).to.be.true; + expect(await escrowFactory.lastEscrow()).to.equal(escrowAddress); + }); + }); + }); }); 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 9fa5a69f5d..a4ec42ba6b 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 @@ -220,7 +220,7 @@ def create_escrow( Creates a new escrow contract. :param token_address: Address of the token to be used in the escrow - :param job_requester_id: ID of the job requester + :param job_requester_id: An off-chain identifier for the job requester :param tx_options: (Optional) Transaction options :return: Address of the created escrow contract @@ -277,6 +277,105 @@ def get_w3_with_priv_key(priv_key: str): except Exception as e: handle_error(e, EscrowClientError) + @requires_signer + def create_fund_and_setup_escrow( + self, + token_address: str, + amount: int, + job_requester_id: str, + escrow_config: EscrowConfig, + tx_options: Optional[TxParams] = None, + ) -> str: + """ + Creates, funds, and sets up a new escrow contract in a single transaction. + + :param token_address: Address of the token to be used in the escrow + :param amount: The token amount to fund the escrow with + :param job_requester_id: An off-chain identifier for the job requester + :param escrow_config: Configuration parameters for escrow setup + :param tx_options: (Optional) Transaction options + + :return: Address of the created escrow contract + + :example: + .. code-block:: python + + from eth_typing import URI + from web3 import Web3 + from web3.middleware import SignAndSendRawMiddlewareBuilder + from web3.providers.auto import load_provider_from_uri + + from human_protocol_sdk.escrow import EscrowClient + + def get_w3_with_priv_key(priv_key: str): + w3 = Web3(load_provider_from_uri( + URI("http://localhost:8545"))) + gas_payer = w3.eth.account.from_key(priv_key) + w3.eth.default_account = gas_payer.address + w3.middleware_onion.inject( + SignAndSendRawMiddlewareBuilder.build(priv_key), + 'SignAndSendRawMiddlewareBuilder', + layer=0, + ) + return (w3, gas_payer) + + (w3, gas_payer) = get_w3_with_priv_key('YOUR_PRIVATE_KEY') + escrow_client = EscrowClient(w3) + + token_address = '0x1234567890abcdef1234567890abcdef12345678' + job_requester_id = 'job-requester' + amount = Web3.to_wei(5, 'ether') # convert from ETH to WEI + escrow_config = EscrowConfig( + recording_oracle_address='0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + reputation_oracle_address='0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + exchange_oracle_address='0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + recording_oracle_fee=100, + reputation_oracle_fee=100, + exchange_oracle_fee=100, + recording_oracle_url='https://example.com/recording', + reputation_oracle_url='https://example.com/reputation', + exchange_oracle_url='https://example.com/exchange', + manifest_url='https://example.com/manifest', + manifest_hash='0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef' + ) + + escrow_address = escrow_client.create_fund_and_setup_escrow( + token_address, + amount, + job_requester_id, + escrow_config + ) + """ + if not Web3.is_address(token_address): + raise EscrowClientError(f"Invalid token address: {token_address}") + + try: + tx_hash = self.factory_contract.functions.createFundAndSetupEscrow( + token_address, + amount, + job_requester_id, + escrow_config.reputation_oracle_address, + escrow_config.recording_oracle_address, + escrow_config.exchange_oracle_address, + escrow_config.reputation_oracle_fee, + escrow_config.recording_oracle_fee, + escrow_config.exchange_oracle_fee, + escrow_config.manifest, + escrow_config.hash, + ).transact(apply_tx_defaults(self.w3, tx_options)) + receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash) + event = next( + ( + self.factory_contract.events.LaunchedV2().process_log(log) + for log in receipt["logs"] + if log["address"] == self.network["factory_address"] + ), + None, + ) + return event.args.escrow if event else None + except Exception as e: + handle_error(e, EscrowClientError) + @requires_signer def setup( self, 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 2a26302de2..cd07585a2c 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 @@ -448,6 +448,171 @@ def test_create_escrow_with_tx_options(self): ) self.assertEqual(result, escrow_address) + def test_create_fund_and_setup_escrow(self): + escrow_address = "0x1234567890123456789012345678901234567890" + token_address = "0x1234567890123456789012345678901234567890" + job_requester_id = "job-requester" + amount = 1000 + + mock_create = MagicMock() + mock_create.transact.return_value = "tx_hash" + self.escrow.factory_contract.functions.createFundAndSetupEscrow = MagicMock( + return_value=mock_create + ) + + mock_event = MagicMock() + mock_event.args.escrow = escrow_address + mock_events = MagicMock() + mock_events.LaunchedV2().process_log.return_value = mock_event + self.escrow.factory_contract.events = mock_events + self.escrow.network["factory_address"] = ( + "0x1234567890123456789012345678901234567890" + ) + self.escrow.w3.eth.wait_for_transaction_receipt = MagicMock( + return_value={"logs": [{"address": self.escrow.network["factory_address"]}]} + ) + + escrow_config = EscrowConfig( + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + 10, + 10, + 10, + "https://www.example.com/manifest", + "hashvalue", + ) + + result = self.escrow.create_fund_and_setup_escrow( + token_address, amount, job_requester_id, escrow_config + ) + + self.escrow.factory_contract.functions.createFundAndSetupEscrow.assert_called_once_with( + token_address, + amount, + job_requester_id, + escrow_config.reputation_oracle_address, + escrow_config.recording_oracle_address, + escrow_config.exchange_oracle_address, + escrow_config.reputation_oracle_fee, + escrow_config.recording_oracle_fee, + escrow_config.exchange_oracle_fee, + escrow_config.manifest, + escrow_config.hash, + ) + mock_create.transact.assert_called_once_with({}) + self.escrow.w3.eth.wait_for_transaction_receipt.assert_called_once_with( + "tx_hash" + ) + self.assertEqual(result, escrow_address) + + def test_create_fund_and_setup_escrow_invalid_token(self): + token_address = "invalid_address" + job_requester_id = "job-requester" + amount = 1000 + + escrow_config = EscrowConfig( + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + 10, + 10, + 10, + "https://www.example.com/manifest", + "hashvalue", + ) + + with self.assertRaises(EscrowClientError) as cm: + self.escrow.create_fund_and_setup_escrow( + token_address, amount, job_requester_id, escrow_config + ) + self.assertEqual(f"Invalid token address: {token_address}", str(cm.exception)) + + def test_create_fund_and_setup_escrow_without_account(self): + mock_provider = MagicMock(spec=HTTPProvider) + w3 = Web3(mock_provider) + mock_chain_id = ChainId.LOCALHOST.value + type(w3.eth).chain_id = PropertyMock(return_value=mock_chain_id) + + escrowClient = EscrowClient(w3) + + token_address = "0x1234567890123456789012345678901234567890" + job_requester_id = "job-requester" + amount = 1000 + escrow_config = EscrowConfig( + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + 10, + 10, + 10, + "https://www.example.com/manifest", + "hashvalue", + ) + with self.assertRaises(RequiresSignerError) as cm: + escrowClient.create_fund_and_setup_escrow( + token_address, amount, job_requester_id, escrow_config + ) + self.assertEqual("You must add an account to Web3 instance", str(cm.exception)) + + def test_create_fund_and_setup_escrow_with_tx_options(self): + mock_create = MagicMock() + mock_create.transact.return_value = "tx_hash" + self.escrow.factory_contract.functions.createFundAndSetupEscrow = MagicMock( + return_value=mock_create + ) + escrow_address = "0x1234567890123456789012345678901234567890" + token_address = "0x1234567890123456789012345678901234567890" + job_requester_id = "job-requester" + amount = 1000 + tx_options = {"gas": 50000} + + mock_event = MagicMock() + mock_event.args.escrow = escrow_address + mock_events = MagicMock() + mock_events.LaunchedV2().process_log.return_value = mock_event + self.escrow.factory_contract.events = mock_events + self.escrow.network["factory_address"] = ( + "0x1234567890123456789012345678901234567890" + ) + self.escrow.w3.eth.wait_for_transaction_receipt = MagicMock( + return_value={"logs": [{"address": self.escrow.network["factory_address"]}]} + ) + + escrow_config = EscrowConfig( + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + "0x1234567890123456789012345678901234567890", + 10, + 10, + 10, + "https://www.example.com/manifest", + "hashvalue", + ) + + result = self.escrow.create_fund_and_setup_escrow( + token_address, amount, job_requester_id, escrow_config, tx_options + ) + + self.escrow.factory_contract.functions.createFundAndSetupEscrow.assert_called_once_with( + token_address, + amount, + job_requester_id, + escrow_config.reputation_oracle_address, + escrow_config.recording_oracle_address, + escrow_config.exchange_oracle_address, + escrow_config.reputation_oracle_fee, + escrow_config.recording_oracle_fee, + escrow_config.exchange_oracle_fee, + escrow_config.manifest, + escrow_config.hash, + ) + mock_create.transact.assert_called_once_with(tx_options) + self.escrow.w3.eth.wait_for_transaction_receipt.assert_called_once_with( + "tx_hash" + ) + self.assertEqual(result, escrow_address) + def test_setup(self): mock_contract = MagicMock() mock_setup = MagicMock() diff --git a/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts b/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts index d40c1f0cfe..cf1f946b96 100644 --- a/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts +++ b/packages/sdk/typescript/human-protocol-sdk/src/escrow.ts @@ -203,8 +203,8 @@ export class EscrowClient extends BaseEthersClient { /** * This function creates an escrow contract that uses the token passed to pay oracle fees and reward workers. * - * @param {string} tokenAddress Token address to use for payouts. - * @param {string} jobRequesterId Job Requester Id + * @param {string} tokenAddress - The address of the token to use for escrow funding. + * @param {string} jobRequesterId - Identifier for the job requester. * @param {Overrides} [txOptions] - Additional transaction parameters (optional, defaults to an empty object). * @returns {Promise} Returns the address of the escrow created. * @@ -263,6 +263,167 @@ export class EscrowClient extends BaseEthersClient { return throwError(e); } } + private verifySetupParameters(escrowConfig: IEscrowConfig) { + const { + recordingOracle, + reputationOracle, + exchangeOracle, + recordingOracleFee, + reputationOracleFee, + exchangeOracleFee, + manifest, + manifestHash, + } = escrowConfig; + + if (!ethers.isAddress(recordingOracle)) { + throw ErrorInvalidRecordingOracleAddressProvided; + } + + if (!ethers.isAddress(reputationOracle)) { + throw ErrorInvalidReputationOracleAddressProvided; + } + + if (!ethers.isAddress(exchangeOracle)) { + throw ErrorInvalidExchangeOracleAddressProvided; + } + + if ( + recordingOracleFee <= 0 || + reputationOracleFee <= 0 || + exchangeOracleFee <= 0 + ) { + throw ErrorAmountMustBeGreaterThanZero; + } + + if (recordingOracleFee + reputationOracleFee + exchangeOracleFee > 100) { + throw ErrorTotalFeeMustBeLessThanHundred; + } + + const isManifestValid = isValidUrl(manifest) || isValidJson(manifest); + if (!isManifestValid) { + throw ErrorInvalidManifest; + } + + if (!manifestHash) { + throw ErrorHashIsEmptyString; + } + } + + /** + * Creates, funds, and sets up a new escrow contract in a single transaction. + * + * @param {string} tokenAddress - The ERC-20 token address used to fund the escrow. + * @param {bigint} amount - The token amount to fund the escrow with. + * @param {string} jobRequesterId - An off-chain identifier for the job requester. + * @param {IEscrowConfig} escrowConfig - Configuration parameters for escrow setup: + * - `recordingOracle`: Address of the recording oracle. + * - `reputationOracle`: Address of the reputation oracle. + * - `exchangeOracle`: Address of the exchange oracle. + * - `recordingOracleFee`: Fee (in basis points or percentage * 100) for the recording oracle. + * - `reputationOracleFee`: Fee for the reputation oracle. + * - `exchangeOracleFee`: Fee for the exchange oracle. + * - `manifest`: URL to the manifest file. + * - `manifestHash`: Hash of the manifest content. + * @param {Overrides} [txOptions] - Additional transaction parameters (optional, defaults to an empty object). + * + * @returns {Promise} Returns the address of the escrow created. + * + * @example + * import { Wallet, ethers } from 'ethers'; + * import { EscrowClient, IERC20__factory } from '@human-protocol/sdk'; + * + * const rpcUrl = 'YOUR_RPC_URL'; + * const privateKey = 'YOUR_PRIVATE_KEY'; + * const provider = new ethers.JsonRpcProvider(rpcUrl); + * const signer = new Wallet(privateKey, provider); + * + * const escrowClient = await EscrowClient.build(signer); + * + * const tokenAddress = '0xTokenAddress'; + * const amount = ethers.parseUnits('1000', 18); + * const jobRequesterId = 'requester-123'; + * + * const token = IERC20__factory.connect(tokenAddress, signer); + * await token.approve(escrowClient.escrowFactoryContract.target, amount); + * + * const escrowConfig = { + * recordingOracle: '0xRecordingOracle', + * reputationOracle: '0xReputationOracle', + * exchangeOracle: '0xExchangeOracle', + * recordingOracleFee: 5n, + * reputationOracleFee: 5n, + * exchangeOracleFee: 5n, + * manifest: 'https://example.com/manifest.json', + * manifestHash: 'manifestHash-123', + * } satisfies IEscrowConfig; + * + * const escrowAddress = await escrowClient.createFundAndSetupEscrow( + * tokenAddress, + * amount, + * jobRequesterId, + * escrowConfig + * ); + * + * console.log('Escrow created at:', escrowAddress); + */ + @requiresSigner + public async createFundAndSetupEscrow( + tokenAddress: string, + amount: bigint, + jobRequesterId: string, + escrowConfig: IEscrowConfig, + txOptions: Overrides = {} + ): Promise { + if (!ethers.isAddress(tokenAddress)) { + throw ErrorInvalidTokenAddress; + } + + this.verifySetupParameters(escrowConfig); + + const { + recordingOracle, + reputationOracle, + exchangeOracle, + recordingOracleFee, + reputationOracleFee, + exchangeOracleFee, + manifest, + manifestHash, + } = escrowConfig; + + try { + const result = await ( + await this.escrowFactoryContract.createFundAndSetupEscrow( + tokenAddress, + amount, + jobRequesterId, + reputationOracle, + recordingOracle, + exchangeOracle, + reputationOracleFee, + recordingOracleFee, + exchangeOracleFee, + manifest, + manifestHash, + this.applyTxDefaults(txOptions) + ) + ).wait(); + + const event = ( + result?.logs?.find(({ topics }) => + topics.includes(ethers.id('LaunchedV2(address,address,string)')) + ) as EventLog + )?.args; + + if (!event) { + throw ErrorLaunchedEventIsNotEmitted; + } + + return event.escrow; + } catch (e: any) { + return throwError(e); + } + } /** * This function sets up the parameters of the escrow. @@ -319,43 +480,12 @@ export class EscrowClient extends BaseEthersClient { manifestHash, } = escrowConfig; - if (!ethers.isAddress(recordingOracle)) { - throw ErrorInvalidRecordingOracleAddressProvided; - } - - if (!ethers.isAddress(reputationOracle)) { - throw ErrorInvalidReputationOracleAddressProvided; - } - - if (!ethers.isAddress(exchangeOracle)) { - throw ErrorInvalidExchangeOracleAddressProvided; - } + this.verifySetupParameters(escrowConfig); if (!ethers.isAddress(escrowAddress)) { throw ErrorInvalidEscrowAddressProvided; } - if ( - recordingOracleFee <= 0 || - reputationOracleFee <= 0 || - exchangeOracleFee <= 0 - ) { - throw ErrorAmountMustBeGreaterThanZero; - } - - if (recordingOracleFee + reputationOracleFee + exchangeOracleFee > 100) { - throw ErrorTotalFeeMustBeLessThanHundred; - } - - const isManifestValid = isValidUrl(manifest) || isValidJson(manifest); - if (!isManifestValid) { - throw ErrorInvalidManifest; - } - - if (!manifestHash) { - throw ErrorHashIsEmptyString; - } - if (!(await this.escrowFactoryContract.hasEscrow(escrowAddress))) { throw ErrorEscrowAddressIsNotProvidedByFactory; } 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 d97f270ff7..012f8a9c77 100644 --- a/packages/sdk/typescript/human-protocol-sdk/test/escrow.test.ts +++ b/packages/sdk/typescript/human-protocol-sdk/test/escrow.test.ts @@ -41,6 +41,7 @@ import { ErrorUnsupportedChainID, ErrorInvalidManifest, InvalidEthereumAddressError, + ContractExecutionError, } from '../src/error'; import { EscrowClient, EscrowUtils } from '../src/escrow'; import { @@ -122,6 +123,7 @@ describe('EscrowClient', () => { mockEscrowFactoryContract = { createEscrow: vi.fn(), + createFundAndSetupEscrow: vi.fn(), hasEscrow: vi.fn(), lastEscrow: vi.fn(), }; @@ -192,20 +194,20 @@ describe('EscrowClient', () => { }); describe('createEscrow', () => { + const jobRequesterId = 'job-requester'; test('should throw an error if tokenAddress is an invalid address', async () => { const invalidAddress = FAKE_ADDRESS; await expect( - escrowClient.createEscrow(invalidAddress, [ethers.ZeroAddress]) + escrowClient.createEscrow(invalidAddress, jobRequesterId) ).rejects.toThrow(ErrorInvalidTokenAddress); }); test('should create an escrow and return its address', async () => { const tokenAddress = ethers.ZeroAddress; - const jobRequesterId = 'job-requester'; + const expectedEscrowAddress = ethers.ZeroAddress; - // Create a spy object for the createEscrow method const createEscrowSpy = vi .spyOn(escrowClient.escrowFactoryContract, 'createEscrow') .mockImplementation(() => ({ @@ -236,15 +238,22 @@ describe('EscrowClient', () => { test('should throw an error if the create an escrow fails', async () => { const tokenAddress = ethers.ZeroAddress; - const jobRequesterId = 'job-requester'; + + const mockedError = ethers.makeError( + 'Custom error', + 'CALL_EXCEPTION' as any + ); + const expectedError = new ContractExecutionError( + (mockedError as any).reason as string + ); escrowClient.escrowFactoryContract.createEscrow.mockRejectedValueOnce( - new Error() + mockedError ); await expect( escrowClient.createEscrow(tokenAddress, jobRequesterId) - ).rejects.toThrow(); + ).rejects.toEqual(expectedError); expect( escrowClient.escrowFactoryContract.createEscrow @@ -253,10 +262,8 @@ describe('EscrowClient', () => { test('should create an escrow and return its address with transaction options', async () => { const tokenAddress = ethers.ZeroAddress; - const jobRequesterId = 'job-requester'; const expectedEscrowAddress = ethers.ZeroAddress; - // Create a spy object for the createEscrow method const createEscrowSpy = vi .spyOn(escrowClient.escrowFactoryContract, 'createEscrow') .mockImplementation(() => ({ @@ -289,7 +296,34 @@ describe('EscrowClient', () => { }); }); - describe('setup', () => { + describe('createFundAndSetupEscrow', () => { + const jobRequesterId = 'job-requester'; + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + const tokenAddress = ethers.ZeroAddress; + const expectedEscrowAddress = ethers.ZeroAddress; + + test('should throw an error if tokenAddress is an invalid address', async () => { + const invalidAddress = FAKE_ADDRESS; + + await expect( + escrowClient.createFundAndSetupEscrow( + invalidAddress, + 10n, + jobRequesterId, + {} as any + ) + ).rejects.toThrow(ErrorInvalidTokenAddress); + }); + test('should throw an error if recordingOracle is an invalid address', async () => { const escrowConfig = { recordingOracle: FAKE_ADDRESS, @@ -299,9 +333,383 @@ describe('EscrowClient', () => { reputationOracleFee: 10n, exchangeOracleFee: 10n, manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorInvalidRecordingOracleAddressProvided); + }); + + test('should throw an error if reputationOracle is an invalid address', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: FAKE_ADDRESS, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorInvalidReputationOracleAddressProvided); + }); + + test('should throw an error if exchangeOracle is an invalid address', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: FAKE_ADDRESS, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorInvalidExchangeOracleAddressProvided); + }); + + test('should throw an error if recordingOracleFee <= 0', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 0n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorAmountMustBeGreaterThanZero); + }); + + test('should throw an error if reputationOracleFee <= 0', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 0n, + exchangeOracleFee: 10n, + manifest: VALID_URL, hash: FAKE_HASH, }; + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorAmountMustBeGreaterThanZero); + }); + + test('should throw an error if exchangeOracleFee <= 0', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 0n, + manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorAmountMustBeGreaterThanZero); + }); + + test('should throw an error if total fee > 100', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 40n, + reputationOracleFee: 40n, + exchangeOracleFee: 40n, + manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorTotalFeeMustBeLessThanHundred); + }); + + test('should throw an error if manifest is an empty string', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: '', + manifestHash: FAKE_HASH, + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorInvalidManifest); + }); + + test('should throw an error if hash is an empty string', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: VALID_URL, + manifestHash: '', + }; + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toThrow(ErrorHashIsEmptyString); + }); + + test('should throw an error if the createFundAndSetupEscrow fails', async () => { + const jobRequesterId = 'job-requester'; + const mockedError = ethers.makeError( + 'Custom error', + 'CALL_EXCEPTION' as any + ); + const expectedError = new ContractExecutionError( + (mockedError as any).reason as string + ); + + escrowClient.escrowFactoryContract.createFundAndSetupEscrow.mockRejectedValueOnce( + mockedError + ); + + await expect( + escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ) + ).rejects.toEqual(expectedError); + + expect( + escrowClient.escrowFactoryContract.createFundAndSetupEscrow + ).toHaveBeenCalledWith( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig.reputationOracle, + escrowConfig.recordingOracle, + escrowConfig.exchangeOracle, + escrowConfig.reputationOracleFee, + escrowConfig.recordingOracleFee, + escrowConfig.exchangeOracleFee, + escrowConfig.manifest, + escrowConfig.manifestHash, + {} + ); + }); + + test('should create, fund and setup an escrow and return its address', async () => { + const createFundAndSetupEscrowSpy = vi + .spyOn(escrowClient.escrowFactoryContract, 'createFundAndSetupEscrow') + .mockImplementation(() => ({ + wait: async () => ({ + logs: [ + { + topics: [ethers.id('LaunchedV2(address,address,string)')], + args: { + escrow: expectedEscrowAddress, + }, + }, + ], + }), + })); + + const result = await escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ); + + expect(createFundAndSetupEscrowSpy).toHaveBeenCalledWith( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig.reputationOracle, + escrowConfig.recordingOracle, + escrowConfig.exchangeOracle, + escrowConfig.reputationOracleFee, + escrowConfig.recordingOracleFee, + escrowConfig.exchangeOracleFee, + escrowConfig.manifest, + escrowConfig.manifestHash, + {} + ); + expect(result).toBe(expectedEscrowAddress); + }); + + test('should create, fund and setup an escrow and return its address with transaction options', async () => { + const createFundAndSetupEscrowSpy = vi + .spyOn(escrowClient.escrowFactoryContract, 'createFundAndSetupEscrow') + .mockImplementation(() => ({ + wait: async () => ({ + logs: [ + { + topics: [ethers.id('LaunchedV2(address,address,string)')], + args: { + escrow: expectedEscrowAddress, + }, + }, + ], + }), + })); + + const txOptions: Overrides = { gasLimit: 45000 }; + + const result = await escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig, + txOptions + ); + + expect(createFundAndSetupEscrowSpy).toHaveBeenCalledWith( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig.reputationOracle, + escrowConfig.recordingOracle, + escrowConfig.exchangeOracle, + escrowConfig.reputationOracleFee, + escrowConfig.recordingOracleFee, + escrowConfig.exchangeOracleFee, + escrowConfig.manifest, + escrowConfig.manifestHash, + txOptions + ); + expect(result).toBe(expectedEscrowAddress); + }); + + test('should create, fund and setup an escrow and accept manifest as a JSON string', async () => { + const escrowConfig = { + recordingOracle: ethers.ZeroAddress, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: '{"foo":"bar"}', + manifestHash: FAKE_HASH, + }; + const createFundAndSetupEscrowSpy = vi + .spyOn(escrowClient.escrowFactoryContract, 'createFundAndSetupEscrow') + .mockImplementation(() => ({ + wait: async () => ({ + logs: [ + { + topics: [ethers.id('LaunchedV2(address,address,string)')], + args: { + escrow: expectedEscrowAddress, + }, + }, + ], + }), + })); + + const result = await escrowClient.createFundAndSetupEscrow( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig + ); + + expect(createFundAndSetupEscrowSpy).toHaveBeenCalledWith( + tokenAddress, + 10n, + jobRequesterId, + escrowConfig.reputationOracle, + escrowConfig.recordingOracle, + escrowConfig.exchangeOracle, + escrowConfig.reputationOracleFee, + escrowConfig.recordingOracleFee, + escrowConfig.exchangeOracleFee, + escrowConfig.manifest, + escrowConfig.manifestHash, + {} + ); + expect(result).toBe(expectedEscrowAddress); + }); + }); + + describe('setup', () => { + test('should throw an error if recordingOracle is an invalid address', async () => { + const escrowConfig = { + recordingOracle: FAKE_ADDRESS, + reputationOracle: ethers.ZeroAddress, + exchangeOracle: ethers.ZeroAddress, + recordingOracleFee: 10n, + reputationOracleFee: 10n, + exchangeOracleFee: 10n, + manifest: VALID_URL, + manifestHash: FAKE_HASH, + }; + await expect( escrowClient.setup(ethers.ZeroAddress, escrowConfig) ).rejects.toThrow(ErrorInvalidRecordingOracleAddressProvided); @@ -316,7 +724,7 @@ describe('EscrowClient', () => { reputationOracleFee: 10n, exchangeOracleFee: 10n, manifest: VALID_URL, - hash: FAKE_HASH, + manifestHash: FAKE_HASH, }; await expect( @@ -333,7 +741,7 @@ describe('EscrowClient', () => { reputationOracleFee: 10n, exchangeOracleFee: 10n, manifest: VALID_URL, - hash: FAKE_HASH, + manifestHash: FAKE_HASH, }; await expect( @@ -350,7 +758,7 @@ describe('EscrowClient', () => { reputationOracleFee: 10n, exchangeOracleFee: 10n, manifest: VALID_URL, - hash: FAKE_HASH, + manifestHash: FAKE_HASH, }; await expect( @@ -377,7 +785,7 @@ describe('EscrowClient', () => { ).rejects.toThrow(ErrorEscrowAddressIsNotProvidedByFactory); }); - test('should throw an error if 0 <= recordingOracleFee', async () => { + test('should throw an error if recordingOracleFee <= 0', async () => { const escrowConfig = { recordingOracle: ethers.ZeroAddress, reputationOracle: ethers.ZeroAddress, @@ -386,7 +794,7 @@ describe('EscrowClient', () => { reputationOracleFee: 10n, exchangeOracleFee: 10n, manifest: VALID_URL, - hash: FAKE_HASH, + manifestHash: FAKE_HASH, }; escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(true); @@ -396,7 +804,7 @@ describe('EscrowClient', () => { ).rejects.toThrow(ErrorAmountMustBeGreaterThanZero); }); - test('should throw an error if 0 <= reputationOracleFee', async () => { + test('should throw an error if reputationOracleFee <= 0', async () => { const escrowConfig = { recordingOracle: ethers.ZeroAddress, reputationOracle: ethers.ZeroAddress, @@ -405,7 +813,7 @@ describe('EscrowClient', () => { reputationOracleFee: 0n, exchangeOracleFee: 10n, manifest: VALID_URL, - hash: FAKE_HASH, + manifestHash: FAKE_HASH, }; escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(true); @@ -415,7 +823,7 @@ describe('EscrowClient', () => { ).rejects.toThrow(ErrorAmountMustBeGreaterThanZero); }); - test('should throw an error if 0 <= exchangeOracleFee', async () => { + test('should throw an error if exchangeOracleFee <= 0', async () => { const escrowConfig = { recordingOracle: ethers.ZeroAddress, reputationOracle: ethers.ZeroAddress, @@ -424,7 +832,7 @@ describe('EscrowClient', () => { reputationOracleFee: 10n, exchangeOracleFee: 0n, manifest: VALID_URL, - hash: FAKE_HASH, + manifestHash: FAKE_HASH, }; escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(true); @@ -434,7 +842,7 @@ describe('EscrowClient', () => { ).rejects.toThrow(ErrorAmountMustBeGreaterThanZero); }); - test('should throw an error if total fee is greater than 100', async () => { + test('should throw an error if total fee > 100', async () => { const escrowConfig = { recordingOracle: ethers.ZeroAddress, reputationOracle: ethers.ZeroAddress, @@ -443,7 +851,7 @@ describe('EscrowClient', () => { reputationOracleFee: 40n, exchangeOracleFee: 40n, manifest: VALID_URL, - hash: FAKE_HASH, + manifestHash: FAKE_HASH, }; escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(true); @@ -515,7 +923,7 @@ describe('EscrowClient', () => { reputationOracleFee: 10n, exchangeOracleFee: 10n, manifest: VALID_URL, - hash: '', + manifestHash: '', }; escrowClient.escrowFactoryContract.hasEscrow.mockReturnValue(true);