diff --git a/abis/contracts/facets/IexecEscrowTokenFacet.json b/abis/contracts/facets/IexecEscrowTokenFacet.json index 29a36134..447db1c4 100644 --- a/abis/contracts/facets/IexecEscrowTokenFacet.json +++ b/abis/contracts/facets/IexecEscrowTokenFacet.json @@ -227,7 +227,7 @@ }, { "internalType": "bytes", - "name": "", + "name": "data", "type": "bytes" } ], diff --git a/abis/human-readable-abis/contracts/tools/testing/ReceiveApprovalTestHelper.sol/ReceiveApprovalTestHelper.json b/abis/human-readable-abis/contracts/tools/testing/ReceiveApprovalTestHelper.sol/ReceiveApprovalTestHelper.json new file mode 100644 index 00000000..c566853d --- /dev/null +++ b/abis/human-readable-abis/contracts/tools/testing/ReceiveApprovalTestHelper.sol/ReceiveApprovalTestHelper.json @@ -0,0 +1,3 @@ +[ + "function matchOrders(tuple(address,uint256,uint256,bytes32,address,address,address,bytes32,bytes),tuple(address,uint256,uint256,bytes32,address,address,address,bytes32,bytes),tuple(address,uint256,uint256,bytes32,uint256,uint256,address,address,address,bytes32,bytes),tuple(address,uint256,address,uint256,address,uint256,address,uint256,bytes32,uint256,uint256,address,address,string,bytes32,bytes)) pure returns (bytes32)" +] diff --git a/contracts/facets/IexecEscrowTokenFacet.sol b/contracts/facets/IexecEscrowTokenFacet.sol index 92761d7a..f4e56cf1 100644 --- a/contracts/facets/IexecEscrowTokenFacet.sol +++ b/contracts/facets/IexecEscrowTokenFacet.sol @@ -7,6 +7,8 @@ import {IexecERC20Core} from "./IexecERC20Core.sol"; import {FacetBase} from "./FacetBase.sol"; import {IexecEscrowToken} from "../interfaces/IexecEscrowToken.sol"; import {IexecTokenSpender} from "../interfaces/IexecTokenSpender.sol"; +import {IexecPoco1} from "../interfaces/IexecPoco1.sol"; +import {IexecLibOrders_v5} from "../libs/IexecLibOrders_v5.sol"; import {PocoStorageLib} from "../libs/PocoStorageLib.sol"; contract IexecEscrowTokenFacet is IexecEscrowToken, IexecTokenSpender, FacetBase, IexecERC20Core { @@ -64,23 +66,121 @@ contract IexecEscrowTokenFacet is IexecEscrowToken, IexecTokenSpender, FacetBase return delta; } - // Token Spender (endpoint for approveAndCallback calls to the proxy) + /*************************************************************************** + * Token Spender: Atomic Deposit+Match * + ***************************************************************************/ + + /** + * @notice Receives approval, deposit and optionally matches orders in one transaction + * + * Usage patterns: + * 1. Simple deposit: RLC.approveAndCall(escrow, amount, "") + * 2. Deposit + match: RLC.approveAndCall(escrow, amount, encodedOrders) + * + * The `data` parameter should be ABI-encoded orders if matching is desired: + * abi.encode(appOrder, datasetOrder, workerpoolOrder, requestOrder) + * + * @dev Important notes: + * - Match orders sponsoring is NOT supported. The requester (sender) always pays for the deal. + * - Clients must compute the exact deal cost and deposit the right amount for the deal to be matched. + * The deal cost = (appPrice + datasetPrice + workerpoolPrice) * volume. + * - If insufficient funds are deposited, the match will fail. + * + * @param sender The address that approved tokens (must be requester if matching) + * @param amount Amount of tokens approved and to be deposited + * @param token Address of the token (must be RLC) + * @param data Optional: ABI-encoded orders for matching + * @return success True if operation succeeded + * + * + * @custom:example + * ```solidity + * // Compute deal cost + * uint256 dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; + * + * // Encode orders + * bytes memory data = abi.encode(appOrder, datasetOrder, workerpoolOrder, requestOrder); + * + * // One transaction does it all + * RLC(token).approveAndCall(iexecProxy, dealCost, data); + * ``` + */ function receiveApproval( address sender, uint256 amount, address token, - bytes calldata + bytes calldata data ) external override returns (bool) { PocoStorageLib.PocoStorage storage $ = PocoStorageLib.getPocoStorage(); require(token == address($.m_baseToken), "wrong-token"); _deposit(sender, amount); _mint(sender, amount); + if (data.length > 0) { + _decodeDataAndMatchOrders(sender, data); + } return true; } + /****************************************************************************** + * Token Spender: Atomic Deposit+Match if used with RLC.approveAndCall * + *****************************************************************************/ + + /** + * @dev Internal function to match orders after deposit + * @param sender The user who deposited (must be the requester) + * @param data ABI-encoded orders + */ + function _decodeDataAndMatchOrders(address sender, bytes calldata data) internal { + // Decode the orders from calldata + ( + IexecLibOrders_v5.AppOrder memory apporder, + IexecLibOrders_v5.DatasetOrder memory datasetorder, + IexecLibOrders_v5.WorkerpoolOrder memory workerpoolorder, + IexecLibOrders_v5.RequestOrder memory requestorder + ) = abi.decode( + data, + ( + IexecLibOrders_v5.AppOrder, + IexecLibOrders_v5.DatasetOrder, + IexecLibOrders_v5.WorkerpoolOrder, + IexecLibOrders_v5.RequestOrder + ) + ); + + // Validate that sender is the requester + if (requestorder.requester != sender) revert("caller-must-be-requester"); + + // Call matchOrders on the IexecPoco1 facet through the diamond + // Using delegatecall for safety: preserves msg.sender context (RLC address in this case) + // Note: matchOrders doesn't use msg.sender, but delegatecall is safer + // in case the implementation changes in the future + (bool success, bytes memory result) = address(this).delegatecall( + abi.encodeWithSelector( + IexecPoco1.matchOrders.selector, + apporder, + datasetorder, + workerpoolorder, + requestorder + ) + ); + + // Handle failure and bubble up revert reason + if (!success) { + if (result.length > 0) { + // Decode and revert with the original error + assembly { + let returndata_size := mload(result) + revert(add(result, 32), returndata_size) + } + } else { + revert("receive-approval-failed"); + } + } + } + function _deposit(address from, uint256 amount) internal { PocoStorageLib.PocoStorage storage $ = PocoStorageLib.getPocoStorage(); - require($.m_baseToken.transferFrom(from, address(this), amount), "failled-transferFrom"); + require($.m_baseToken.transferFrom(from, address(this), amount), "failed-transferFrom"); } function _withdraw(address to, uint256 amount) internal { diff --git a/contracts/facets/IexecPoco1Facet.sol b/contracts/facets/IexecPoco1Facet.sol index 5d10b1e0..a06d6e17 100644 --- a/contracts/facets/IexecPoco1Facet.sol +++ b/contracts/facets/IexecPoco1Facet.sol @@ -140,6 +140,11 @@ contract IexecPoco1Facet is /** * Match orders. The requester gets debited. * + * @notice This function does not use `msg.sender` to determine who pays for the deal. + * The sponsor is always set to `_requestorder.requester`, regardless of who calls this function. + * This design allows the function to be safely called via delegatecall from other facets + * (e.g., IexecEscrowTokenFacet.receiveApproval) without security concerns. + * * @param _apporder The app order. * @param _datasetorder The dataset order. * @param _workerpoolorder The workerpool order. @@ -161,6 +166,7 @@ contract IexecPoco1Facet is ); } + // TODO: check if we want to modify sponsor origin to be a variable instead of msg.sender /** * Sponsor match orders for a requester. * Unlike the standard `matchOrders(..)` hook where the requester pays for diff --git a/contracts/interfaces/IexecEscrowToken.sol b/contracts/interfaces/IexecEscrowToken.sol index cf599441..c27db6a9 100644 --- a/contracts/interfaces/IexecEscrowToken.sol +++ b/contracts/interfaces/IexecEscrowToken.sol @@ -6,7 +6,6 @@ pragma solidity ^0.8.0; interface IexecEscrowToken { receive() external payable; fallback() external payable; - function deposit(uint256) external returns (bool); function depositFor(uint256, address) external returns (bool); function depositForArray(uint256[] calldata, address[] calldata) external returns (bool); diff --git a/contracts/tools/testing/ReceiveApprovalTestHelper.sol b/contracts/tools/testing/ReceiveApprovalTestHelper.sol new file mode 100644 index 00000000..6f61ba52 --- /dev/null +++ b/contracts/tools/testing/ReceiveApprovalTestHelper.sol @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025 IEXEC BLOCKCHAIN TECH +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.0; + +import {IexecLibOrders_v5} from "../../libs/IexecLibOrders_v5.sol"; + +/** + * @title ReceiveApprovalTestHelper + * @notice Helper contract to test edge cases in receiveApproval function + * @dev This contract simulates a facet that fails silently (no error data) + */ +contract ReceiveApprovalTestHelper { + /** + * @notice Mock matchOrders function that fails without returning error data + * @dev Uses assembly to revert without data, simulating the edge case where + * delegatecall fails and result.length == 0 + */ + function matchOrders( + IexecLibOrders_v5.AppOrder calldata, + IexecLibOrders_v5.DatasetOrder calldata, + IexecLibOrders_v5.WorkerpoolOrder calldata, + IexecLibOrders_v5.RequestOrder calldata + ) external pure returns (bytes32) { + // Revert without any error data + // This simulates: delegatecall fails with success=false and result.length=0 + assembly { + revert(0, 0) + } + } +} diff --git a/docs/solidity/index.md b/docs/solidity/index.md index fa5ddcf9..c03f7b17 100644 --- a/docs/solidity/index.md +++ b/docs/solidity/index.md @@ -247,9 +247,39 @@ function recover() external returns (uint256) ### receiveApproval ```solidity -function receiveApproval(address sender, uint256 amount, address token, bytes) external returns (bool) +function receiveApproval(address sender, uint256 amount, address token, bytes data) external returns (bool) ``` +Receives approval and optionally matches orders in one transaction + +Usage patterns: +1. Simple deposit: RLC.approveAndCall(escrow, amount, "") +2. Deposit + match: RLC.approveAndCall(escrow, amount, encodedOrders) + +The `data` parameter should be ABI-encoded orders if matching is desired: +abi.encode(appOrder, datasetOrder, workerpoolOrder, requestOrder) + +_Important notes: +- Match orders sponsoring is NOT supported. The requester (sender) always pays for the deal. +- Clients must compute the exact deal cost and deposit the right amount for the deal to be matched. + The deal cost = (appPrice + datasetPrice + workerpoolPrice) * volume. +- If insufficient funds are deposited, the match will fail._ + +#### Parameters + +| Name | Type | Description | +| ---- | ---- | ----------- | +| sender | address | The address that approved tokens (must be requester if matching) | +| amount | uint256 | Amount of tokens approved and to be deposited | +| token | address | Address of the token (must be RLC) | +| data | bytes | Optional: ABI-encoded orders for matching | + +#### Return Values + +| Name | Type | Description | +| ---- | ---- | ----------- | +| [0] | bool | success True if operation succeeded @custom:example ```solidity // Compute deal cost uint256 dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; // Encode orders bytes memory data = abi.encode(appOrder, datasetOrder, workerpoolOrder, requestOrder); // One transaction does it all RLC(token).approveAndCall(iexecProxy, dealCost, data); ``` | + ## IexecOrderManagementFacet ### manageAppOrder @@ -343,6 +373,11 @@ function matchOrders(struct IexecLibOrders_v5.AppOrder _apporder, struct IexecLi Match orders. The requester gets debited. +This function does not use `msg.sender` to determine who pays for the deal. +The sponsor is always set to `_requestorder.requester`, regardless of who calls this function. +This design allows the function to be safely called via delegatecall from other facets +(e.g., IexecEscrowTokenFacet.receiveApproval) without security concerns. + #### Parameters | Name | Type | Description | diff --git a/test/byContract/IexecEscrow/IexecEscrowToken.receiveApproval.test.ts b/test/byContract/IexecEscrow/IexecEscrowToken.receiveApproval.test.ts new file mode 100644 index 00000000..7b7e7474 --- /dev/null +++ b/test/byContract/IexecEscrow/IexecEscrowToken.receiveApproval.test.ts @@ -0,0 +1,522 @@ +// SPDX-FileCopyrightText: 2024-2025 IEXEC BLOCKCHAIN TECH +// SPDX-License-Identifier: Apache-2.0 + +import { AddressZero } from '@ethersproject/constants'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { FacetCutAction } from 'hardhat-deploy/dist/types'; +import { + IexecInterfaceToken, + IexecInterfaceToken__factory, + RLC, + RLC__factory, + ReceiveApprovalTestHelper__factory, +} from '../../../typechain'; +import { TAG_TEE } from '../../../utils/constants'; +import { + IexecOrders, + OrdersActors, + OrdersAssets, + OrdersPrices, + buildOrders, + signOrders, +} from '../../../utils/createOrders'; +import { encodeOrders } from '../../../utils/odb-tools'; +import { getDealId, getIexecAccounts } from '../../../utils/poco-tools'; +import { getFunctionSelectors } from '../../../utils/proxy-tools'; +import { IexecWrapper } from '../../utils/IexecWrapper'; +import { loadHardhatFixtureDeployment } from '../../utils/hardhat-fixture-deployer'; + +const appPrice = 1000n; +const datasetPrice = 1_000_000n; +const workerpoolPrice = 1_000_000_000n; +const volume = 1n; + +describe('IexecEscrowToken-receiveApproval', () => { + let proxyAddress: string; + let iexecPoco: IexecInterfaceToken; + let iexecPocoAsRequester: IexecInterfaceToken; + let rlcInstance: RLC; + let rlcInstanceAsRequester: RLC; + let [ + iexecAdmin, + requester, + scheduler, + appProvider, + datasetProvider, + anyone, + ]: SignerWithAddress[] = []; + let iexecWrapper: IexecWrapper; + let [appAddress, datasetAddress, workerpoolAddress]: string[] = []; + let ordersActors: OrdersActors; + let ordersAssets: OrdersAssets; + let ordersPrices: OrdersPrices; + + beforeEach('Deploy', async () => { + proxyAddress = await loadHardhatFixtureDeployment(); + await loadFixture(initFixture); + }); + + async function initFixture() { + const accounts = await getIexecAccounts(); + ({ iexecAdmin, requester, scheduler, appProvider, datasetProvider, anyone } = accounts); + + iexecPoco = IexecInterfaceToken__factory.connect(proxyAddress, anyone); + iexecPocoAsRequester = iexecPoco.connect(requester); + + rlcInstance = RLC__factory.connect(await iexecPoco.token(), anyone); + rlcInstanceAsRequester = rlcInstance.connect(requester); + + iexecWrapper = new IexecWrapper(proxyAddress, accounts); + ({ appAddress, datasetAddress, workerpoolAddress } = await iexecWrapper.createAssets()); + + ordersActors = { + appOwner: appProvider, + datasetOwner: datasetProvider, + workerpoolOwner: scheduler, + requester: requester, + }; + ordersAssets = { + app: appAddress, + dataset: datasetAddress, + workerpool: workerpoolAddress, + }; + ordersPrices = { + app: appPrice, + dataset: datasetPrice, + workerpool: workerpoolPrice, + }; + + // Transfer RLC to accounts for testing + const totalAmount = (appPrice + datasetPrice + workerpoolPrice) * volume * 100n; + await rlcInstance + .connect(iexecAdmin) + .transfer(requester.address, totalAmount) + .then((tx) => tx.wait()); + await rlcInstance + .connect(iexecAdmin) + .transfer(scheduler.address, totalAmount) + .then((tx) => tx.wait()); + } + + describe('Basic receiveApproval (backward compatibility)', () => { + it('Should deposit tokens via approveAndCall with empty data', async () => { + const depositAmount = 1000n; + const initialTotalSupply = await iexecPoco.totalSupply(); + const initialBalance = await iexecPoco.balanceOf(requester.address); + + const tx = await rlcInstanceAsRequester.approveAndCall( + proxyAddress, + depositAmount, + '0x', + ); + + // Verify RLC transfer + await expect(tx) + .to.emit(rlcInstance, 'Transfer') + .withArgs(requester.address, proxyAddress, depositAmount); + + // Verify internal mint + await expect(tx) + .to.emit(iexecPoco, 'Transfer') + .withArgs(AddressZero, requester.address, depositAmount); + + expect(await iexecPoco.totalSupply()).to.equal(initialTotalSupply + depositAmount); + expect(await iexecPoco.balanceOf(requester.address)).to.equal( + initialBalance + depositAmount, + ); + }); + + it('Should deposit 0 tokens via approveAndCall', async () => { + const depositAmount = 0n; + + const tx = rlcInstanceAsRequester.approveAndCall(proxyAddress, depositAmount, '0x'); + + await expect(tx) + .to.emit(rlcInstance, 'Transfer') + .withArgs(requester.address, proxyAddress, depositAmount); + + await expect(tx) + .to.emit(iexecPoco, 'Transfer') + .withArgs(AddressZero, requester.address, depositAmount); + }); + }); + + describe('receiveApproval with Order Matching', () => { + it('Should approve, deposit and match orders with all assets', async () => { + const orders = buildOrders({ + assets: ordersAssets, + prices: ordersPrices, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + }); + + await signAndPrepareOrders(orders); + + const dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; + const schedulerStake = await iexecWrapper.computeSchedulerDealStake( + workerpoolPrice, + volume, + ); + + await iexecWrapper.depositInIexecAccount(scheduler, schedulerStake); + + const initialBalance = await iexecPoco.balanceOf(requester.address); + const initialTotalSupply = await iexecPoco.totalSupply(); + + const encodedOrders = encodeOrdersForCallback(orders); + + const tx = await rlcInstanceAsRequester.approveAndCall( + proxyAddress, + dealCost, + encodedOrders, + ); + + // Verify RLC transfer from requester to proxy + await expect(tx) + .to.emit(rlcInstance, 'Transfer') + .withArgs(requester.address, proxyAddress, dealCost); + + // Verify internal mint (deposit) + await expect(tx) + .to.emit(iexecPoco, 'Transfer') + .withArgs(AddressZero, requester.address, dealCost); + + // Verify deal was matched + const { appOrderHash, datasetOrderHash, workerpoolOrderHash, requestOrderHash } = + iexecWrapper.hashOrders(orders); + + const dealId = getDealId(iexecWrapper.getDomain(), orders.requester); + + await expect(tx) + .to.emit(iexecPoco, 'SchedulerNotice') + .withArgs(workerpoolAddress, dealId); + + await expect(tx) + .to.emit(iexecPoco, 'OrdersMatched') + .withArgs( + dealId, + appOrderHash, + datasetOrderHash, + workerpoolOrderHash, + requestOrderHash, + volume, + ); + + // Verify total supply increased + expect(await iexecPoco.totalSupply()).to.equal(initialTotalSupply + dealCost); + // The available balance remains unchanged because the deposit is immediately frozen + expect(await iexecPoco.balanceOf(requester.address)).to.equal(initialBalance); + // Verify frozen balance + expect(await iexecPoco.frozenOf(requester.address)).to.equal(dealCost); + }); + + it('Should approve, deposit and match orders without dataset', async () => { + const ordersWithoutDataset = buildOrders({ + assets: { ...ordersAssets, dataset: AddressZero }, + prices: { app: appPrice, workerpool: workerpoolPrice }, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + }); + + await signAndPrepareOrders(ordersWithoutDataset); + + const dealCost = (appPrice + workerpoolPrice) * volume; + const schedulerStake = await iexecWrapper.computeSchedulerDealStake( + workerpoolPrice, + volume, + ); + + await iexecWrapper.depositInIexecAccount(scheduler, schedulerStake); + + const { appOrderHash, workerpoolOrderHash, requestOrderHash } = + iexecWrapper.hashOrders(ordersWithoutDataset); + + const dealId = getDealId(iexecWrapper.getDomain(), ordersWithoutDataset.requester); + const encodedOrders = encodeOrdersForCallback(ordersWithoutDataset); + + const tx = rlcInstanceAsRequester.approveAndCall(proxyAddress, dealCost, encodedOrders); + + await expect(tx) + .to.emit(rlcInstance, 'Transfer') + .withArgs(requester.address, proxyAddress, dealCost); + + await expect(tx) + .to.emit(iexecPoco, 'Transfer') + .withArgs(AddressZero, requester.address, dealCost); + + await expect(tx) + .to.emit(iexecPoco, 'OrdersMatched') + .withArgs( + dealId, + appOrderHash, + ethers.ZeroHash, + workerpoolOrderHash, + requestOrderHash, + volume, + ); + + expect(await iexecPoco.frozenOf(requester.address)).to.equal(dealCost); + }); + + it('Should work when requester has existing balance', async () => { + // First, deposit some tokens traditionally + const existingDeposit = 500_000n; + await rlcInstanceAsRequester.approveAndCall(proxyAddress, existingDeposit, '0x'); + + const orders = buildOrders({ + assets: ordersAssets, + prices: ordersPrices, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + }); + + await signAndPrepareOrders(orders); + + const dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; + const schedulerStake = await iexecWrapper.computeSchedulerDealStake( + workerpoolPrice, + volume, + ); + + await iexecWrapper.depositInIexecAccount(scheduler, schedulerStake); + + const initialBalance = await iexecPoco.balanceOf(requester.address); + const encodedOrders = encodeOrdersForCallback(orders); + await rlcInstanceAsRequester.approveAndCall(proxyAddress, dealCost, encodedOrders); + + // The available balance remains unchanged because the deposit is immediately frozen + expect(await iexecPoco.balanceOf(requester.address)).to.equal(initialBalance); + expect(await iexecPoco.frozenOf(requester.address)).to.equal(dealCost); + }); + + it('Should not match orders when caller is not requester', async () => { + const orders = buildOrders({ + assets: ordersAssets, + prices: ordersPrices, + requester: anyone.address, // Different from caller + tag: TAG_TEE, + volume: volume, + }); + + const dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; + const encodedOrders = encodeOrdersForCallback(orders); + + await expect( + rlcInstanceAsRequester.approveAndCall(proxyAddress, dealCost, encodedOrders), + ).to.be.revertedWith('caller-must-be-requester'); + }); + + it('Should bubble up error when matchOrders fails', async () => { + const orders = buildOrders({ + assets: ordersAssets, + prices: ordersPrices, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + }); + + await signAndPrepareOrders(orders); + + const dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; + const schedulerStake = await iexecWrapper.computeSchedulerDealStake( + workerpoolPrice, + volume, + ); + + await iexecWrapper.depositInIexecAccount(scheduler, schedulerStake); + + const insufficientAmount = dealCost - 1n; + const encodedOrders = encodeOrdersForCallback(orders); + + // Should revert from matchOrders due to insufficient balance + await expect( + rlcInstanceAsRequester.approveAndCall( + proxyAddress, + insufficientAmount, + encodedOrders, + ), + ).to.be.revertedWith('IexecEscrow: Transfer amount exceeds balance'); + }); + + it('Should not match orders with invalid calldata', async () => { + const dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; + const invalidData = '0x1234'; // Too short to be valid + + await expect(rlcInstanceAsRequester.approveAndCall(proxyAddress, dealCost, invalidData)) + .to.be.reverted; // Will fail during abi.decode + }); + + it('Should handle multiple sequential approveAndCall operations', async () => { + const dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; + const schedulerStake = await iexecWrapper.computeSchedulerDealStake( + workerpoolPrice, + volume, + ); + + // Deposit enough stake for both deals + await iexecWrapper.depositInIexecAccount(scheduler, schedulerStake * 2n); + + // First operation + const orders1 = buildOrders({ + assets: ordersAssets, + prices: ordersPrices, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + salt: ethers.hexlify(ethers.randomBytes(32)), + }); + + await signAndPrepareOrders(orders1); + + const encodedOrders1 = encodeOrdersForCallback(orders1); + + const tx1 = await rlcInstanceAsRequester.approveAndCall( + proxyAddress, + dealCost, + encodedOrders1, + ); + + const dealId1 = getDealId(iexecWrapper.getDomain(), orders1.requester); + await expect(tx1) + .to.emit(iexecPoco, 'SchedulerNotice') + .withArgs(workerpoolAddress, dealId1); + + // Second operation with different salt + const orders2 = buildOrders({ + assets: ordersAssets, + prices: ordersPrices, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + salt: ethers.hexlify(ethers.randomBytes(32)), + }); + + await signAndPrepareOrders(orders2); + + const encodedOrders2 = encodeOrdersForCallback(orders2); + + const tx2 = await rlcInstanceAsRequester.approveAndCall( + proxyAddress, + dealCost, + encodedOrders2, + ); + + const dealId2 = getDealId(iexecWrapper.getDomain(), orders2.requester); + await expect(tx2) + .to.emit(iexecPoco, 'SchedulerNotice') + .withArgs(workerpoolAddress, dealId2); + + // Both deals should be frozen + expect(await iexecPoco.frozenOf(requester.address)).to.equal(dealCost * 2n); + }); + + it('Should handle zero price orders', async () => { + const ordersZeroPrice = buildOrders({ + assets: ordersAssets, + prices: { app: 0n, dataset: 0n, workerpool: 0n }, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + }); + + await signAndPrepareOrders(ordersZeroPrice); + + const schedulerStake = await iexecWrapper.computeSchedulerDealStake(0n, volume); + await iexecWrapper.depositInIexecAccount(scheduler, schedulerStake); + + const dealCost = 0n; + const encodedOrders = encodeOrdersForCallback(ordersZeroPrice); + + const tx = await rlcInstanceAsRequester.approveAndCall( + proxyAddress, + dealCost, + encodedOrders, + ); + + await expect(tx) + .to.emit(iexecPoco, 'Transfer') + .withArgs(AddressZero, requester.address, dealCost); + + expect(await iexecPoco.frozenOf(requester.address)).to.equal(0n); + }); + + it('Should revert with receive-approval-failed when delegatecall fails silently', async () => { + // Deploy the mock helper contract that fails silently + const mockFacet = await new ReceiveApprovalTestHelper__factory() + .connect(iexecAdmin) + .deploy() + .then((tx) => tx.waitForDeployment()); + + const mockFacetAddress = await mockFacet.getAddress(); + + const mockFactory = new ReceiveApprovalTestHelper__factory(); + const matchOrdersSelector = getFunctionSelectors(mockFactory)[0]; // matchOrders is the only function + + const diamondLoupe = await ethers.getContractAt('DiamondLoupeFacet', proxyAddress); + const originalFacetAddress = await diamondLoupe.facetAddress(matchOrdersSelector); + const diamondCut = await ethers.getContractAt( + 'DiamondCutFacet', + proxyAddress, + iexecAdmin, + ); + + await diamondCut.diamondCut( + [ + { + facetAddress: mockFacetAddress, + action: FacetCutAction.Replace, + functionSelectors: [matchOrdersSelector], + }, + ], + ethers.ZeroAddress, + '0x', + ); + + // Now test receiveApproval - it will delegatecall to our mock which fails silently + const depositAmount = 1000n; + const orders = buildOrders({ + assets: ordersAssets, + prices: ordersPrices, + requester: requester.address, + tag: TAG_TEE, + volume: volume, + }); + + const encodedOrders = encodeOrdersForCallback(orders); + + const tx = rlcInstanceAsRequester.approveAndCall( + proxyAddress, + depositAmount, + encodedOrders, + ); + await expect(tx).to.be.revertedWith('receive-approval-failed'); + + // Restore original facet + await diamondCut.diamondCut( + [ + { + facetAddress: originalFacetAddress, + action: FacetCutAction.Replace, + functionSelectors: [matchOrdersSelector], + }, + ], + ethers.ZeroAddress, + '0x', + ); + }); + }); + + async function signAndPrepareOrders(orders: IexecOrders): Promise { + await signOrders(iexecWrapper.getDomain(), orders, ordersActors); + } + + function encodeOrdersForCallback(orders: IexecOrders): string { + return encodeOrders(orders.app, orders.dataset, orders.workerpool, orders.requester); + } +}); diff --git a/utils/odb-tools.ts b/utils/odb-tools.ts index 1def4dee..d620628b 100644 --- a/utils/odb-tools.ts +++ b/utils/odb-tools.ts @@ -167,3 +167,36 @@ export function hashStruct( return TypedDataEncoder.hash(typedDataDomain, types, message); } + +/** + * Encode orders for callback data in receiveApproval. + * Uses typechain-generated struct definitions to ensure type consistency. + * @param appOrder App order struct + * @param datasetOrder Dataset order struct + * @param workerpoolOrder Workerpool order struct + * @param requestOrder Request order struct + * @returns ABI-encoded orders + */ +export function encodeOrders( + appOrder: Record, + datasetOrder: Record, + workerpoolOrder: Record, + requestOrder: Record, +): string { + // These types match the typechain-generated structs in IexecLibOrders_v5 + // AppOrderStruct, DatasetOrderStruct, WorkerpoolOrderStruct, RequestOrderStruct + // By using named tuple components, ethers can encode objects with named properties + const appOrderType = + 'tuple(address app, uint256 appprice, uint256 volume, bytes32 tag, address datasetrestrict, address workerpoolrestrict, address requesterrestrict, bytes32 salt, bytes sign)'; + const datasetOrderType = + 'tuple(address dataset, uint256 datasetprice, uint256 volume, bytes32 tag, address apprestrict, address workerpoolrestrict, address requesterrestrict, bytes32 salt, bytes sign)'; + const workerpoolOrderType = + 'tuple(address workerpool, uint256 workerpoolprice, uint256 volume, bytes32 tag, uint256 category, uint256 trust, address apprestrict, address datasetrestrict, address requesterrestrict, bytes32 salt, bytes sign)'; + const requestOrderType = + 'tuple(address app, uint256 appmaxprice, address dataset, uint256 datasetmaxprice, address workerpool, uint256 workerpoolmaxprice, address requester, uint256 volume, bytes32 tag, uint256 category, uint256 trust, address beneficiary, address callback, string params, bytes32 salt, bytes sign)'; + + return ethers.AbiCoder.defaultAbiCoder().encode( + [appOrderType, datasetOrderType, workerpoolOrderType, requestOrderType], + [appOrder, datasetOrder, workerpoolOrder, requestOrder], + ); +}