Skip to content

Commit af281bf

Browse files
authored
feat: receive approval generic (#323)
1 parent 900c30e commit af281bf

File tree

4 files changed

+126
-75
lines changed

4 files changed

+126
-75
lines changed

contracts/facets/IexecEscrowTokenFacet.sol

Lines changed: 71 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -70,25 +70,33 @@ contract IexecEscrowTokenFacet is IexecEscrowToken, IexecTokenSpender, IexecERC2
7070
***************************************************************************/
7171

7272
/**
73-
* @notice Receives approval, deposit and optionally matches orders in one transaction
73+
* @notice Receives approval, deposit and optionally executes an operation in one transaction
7474
*
7575
* Usage patterns:
7676
* 1. Simple deposit: RLC.approveAndCall(escrow, amount, "")
77-
* 2. Deposit + match: RLC.approveAndCall(escrow, amount, encodedOrders)
77+
* 2. Deposit + operation: RLC.approveAndCall(escrow, amount, encodedOperation)
7878
*
79-
* The `data` parameter should be ABI-encoded orders if matching is desired:
80-
* abi.encode(appOrder, datasetOrder, workerpoolOrder, requestOrder)
79+
* The `data` parameter should include a function selector (first 4 bytes) to identify
80+
* the operation, followed by ABI-encoded parameters. Supported operations:
81+
* - matchOrders: Validates sender is requester, then matches orders
8182
*
82-
* @dev Important notes:
83-
* - Match orders sponsoring is NOT supported. The requester (sender) always pays for the deal.
84-
* - Clients must compute the exact deal cost and deposit the right amount for the deal to be matched.
83+
* @dev Implementation details:
84+
* - Deposits tokens first, then executes the operation if data is provided
85+
* - Extracts function selector from data to determine which operation
86+
* - Each operation has a validator (_validateMatchOrders, etc.) for preconditions
87+
* - After validation, _executeOperation performs the delegatecall
88+
* - Error handling is generalized: bubbles up revert reasons or returns 'operation-failed'
89+
* - Future operations can be added by implementing a validator and adding a selector case
90+
*
91+
* @dev matchOrders specific notes:
92+
* - Sponsoring is NOT supported. The requester (sender) always pays for the deal.
93+
* - Clients must compute the exact deal cost and deposit the right amount.
8594
* The deal cost = (appPrice + datasetPrice + workerpoolPrice) * volume.
86-
* - If insufficient funds are deposited, the match will fail.
8795
*
88-
* @param sender The address that approved tokens (must be requester if matching)
96+
* @param sender The address that approved tokens
8997
* @param amount Amount of tokens approved and to be deposited
9098
* @param token Address of the token (must be RLC)
91-
* @param data Optional: ABI-encoded orders for matching
99+
* @param data Optional: Function selector + ABI-encoded parameters for operation
92100
* @return success True if operation succeeded
93101
*
94102
*
@@ -97,10 +105,16 @@ contract IexecEscrowTokenFacet is IexecEscrowToken, IexecTokenSpender, IexecERC2
97105
* // Compute deal cost
98106
* uint256 dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume;
99107
*
100-
* // Encode orders
101-
* bytes memory data = abi.encode(appOrder, datasetOrder, workerpoolOrder, requestOrder);
108+
* // Encode matchOrders operation with selector
109+
* bytes memory data = abi.encodeWithSelector(
110+
* IexecPoco1.matchOrders.selector,
111+
* appOrder,
112+
* datasetOrder,
113+
* workerpoolOrder,
114+
* requestOrder
115+
* );
102116
*
103-
* // One transaction does it all
117+
* // One transaction does it all: approve, deposit, and match
104118
* RLC(token).approveAndCall(iexecProxy, dealCost, data);
105119
* ```
106120
*/
@@ -114,54 +128,28 @@ contract IexecEscrowTokenFacet is IexecEscrowToken, IexecTokenSpender, IexecERC2
114128
require(token == address($.m_baseToken), "wrong-token");
115129
_deposit(sender, amount);
116130
_mint(sender, amount);
131+
117132
if (data.length > 0) {
118-
_decodeDataAndMatchOrders(sender, data);
133+
_executeOperation(sender, data);
119134
}
120135
return true;
121136
}
122137

123-
/******************************************************************************
124-
* Token Spender: Atomic Deposit+Match if used with RLC.approveAndCall *
125-
*****************************************************************************/
126-
127-
/**
128-
* @dev Internal function to match orders after deposit
129-
* @param sender The user who deposited (must be the requester)
130-
* @param data ABI-encoded orders
131-
*/
132-
function _decodeDataAndMatchOrders(address sender, bytes calldata data) internal {
133-
// Decode the orders from calldata
134-
(
135-
IexecLibOrders_v5.AppOrder memory apporder,
136-
IexecLibOrders_v5.DatasetOrder memory datasetorder,
137-
IexecLibOrders_v5.WorkerpoolOrder memory workerpoolorder,
138-
IexecLibOrders_v5.RequestOrder memory requestorder
139-
) = abi.decode(
140-
data,
141-
(
142-
IexecLibOrders_v5.AppOrder,
143-
IexecLibOrders_v5.DatasetOrder,
144-
IexecLibOrders_v5.WorkerpoolOrder,
145-
IexecLibOrders_v5.RequestOrder
146-
)
147-
);
138+
function _executeOperation(address sender, bytes calldata data) internal {
139+
// Extract the function selector (first 4 bytes)
140+
bytes4 selector = bytes4(data[:4]);
148141

149-
// Validate that sender is the requester
150-
if (requestorder.requester != sender) revert("caller-must-be-requester");
142+
// Validate operation-specific preconditions before execution
143+
if (selector == IexecPoco1.matchOrders.selector) {
144+
_validateMatchOrders(sender, data);
145+
} else {
146+
revert("unsupported-operation");
147+
}
151148

152-
// Call matchOrders on the IexecPoco1 facet through the diamond
153-
// Using delegatecall for safety: preserves msg.sender context (RLC address in this case)
154-
// Note: matchOrders doesn't use msg.sender, but delegatecall is safer
155-
// in case the implementation changes in the future
156-
(bool success, bytes memory result) = address(this).delegatecall(
157-
abi.encodeWithSelector(
158-
IexecPoco1.matchOrders.selector,
159-
apporder,
160-
datasetorder,
161-
workerpoolorder,
162-
requestorder
163-
)
164-
);
149+
// Execute the operation via delegatecall
150+
// This preserves msg.sender context and allows the operation to access
151+
// the diamond's storage and functions
152+
(bool success, bytes memory result) = address(this).delegatecall(data);
165153

166154
// Handle failure and bubble up revert reason
167155
if (!success) {
@@ -172,11 +160,39 @@ contract IexecEscrowTokenFacet is IexecEscrowToken, IexecTokenSpender, IexecERC2
172160
revert(add(result, 32), returndata_size)
173161
}
174162
} else {
175-
revert("receive-approval-failed");
163+
revert("operation-failed");
176164
}
177165
}
178166
}
179167

168+
/******************************************************************************
169+
* Token Spender: Atomic Deposit+Match if used with RLC.approveAndCall *
170+
*****************************************************************************/
171+
172+
/**
173+
* @dev Validates matchOrders preconditions
174+
* @param sender The user who deposited (must be the requester)
175+
* @param data ABI-encoded matchOrders call with orders
176+
*/
177+
function _validateMatchOrders(address sender, bytes calldata data) internal pure {
178+
// Decode only the request order to validate the requester
179+
// Full decoding: (AppOrder, DatasetOrder, WorkerpoolOrder, RequestOrder)
180+
// We only need to check requestorder.requester
181+
(, , , IexecLibOrders_v5.RequestOrder memory requestorder) = abi.decode(
182+
data[4:],
183+
(
184+
IexecLibOrders_v5.AppOrder,
185+
IexecLibOrders_v5.DatasetOrder,
186+
IexecLibOrders_v5.WorkerpoolOrder,
187+
IexecLibOrders_v5.RequestOrder
188+
)
189+
);
190+
191+
// Validate that sender is the requester
192+
// This ensures the caller is authorized to create this deal
193+
if (requestorder.requester != sender) revert("caller-must-be-requester");
194+
}
195+
180196
function _deposit(address from, uint256 amount) internal {
181197
PocoStorageLib.PocoStorage storage $ = PocoStorageLib.getPocoStorage();
182198
require($.m_baseToken.transferFrom(from, address(this), amount), "failed-transferFrom");

docs/solidity/index.md

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -252,35 +252,43 @@ function recover() external returns (uint256)
252252
function receiveApproval(address sender, uint256 amount, address token, bytes data) external returns (bool)
253253
```
254254

255-
Receives approval, deposit and optionally matches orders in one transaction
255+
Receives approval, deposit and optionally executes an operation in one transaction
256256

257257
Usage patterns:
258258
1. Simple deposit: RLC.approveAndCall(escrow, amount, "")
259-
2. Deposit + match: RLC.approveAndCall(escrow, amount, encodedOrders)
259+
2. Deposit + operation: RLC.approveAndCall(escrow, amount, encodedOperation)
260260

261-
The `data` parameter should be ABI-encoded orders if matching is desired:
262-
abi.encode(appOrder, datasetOrder, workerpoolOrder, requestOrder)
261+
The `data` parameter should include a function selector (first 4 bytes) to identify
262+
the operation, followed by ABI-encoded parameters. Supported operations:
263+
- matchOrders: Validates sender is requester, then matches orders
263264

264-
_Important notes:
265-
- Match orders sponsoring is NOT supported. The requester (sender) always pays for the deal.
266-
- Clients must compute the exact deal cost and deposit the right amount for the deal to be matched.
267-
The deal cost = (appPrice + datasetPrice + workerpoolPrice) * volume.
268-
- If insufficient funds are deposited, the match will fail._
265+
_Implementation details:
266+
- Deposits tokens first, then executes the operation if data is provided
267+
- Extracts function selector from data to determine which operation
268+
- Each operation has a validator (_validateMatchOrders, etc.) for preconditions
269+
- After validation, _executeOperation performs the delegatecall
270+
- Error handling is generalized: bubbles up revert reasons or returns 'operation-failed'
271+
- Future operations can be added by implementing a validator and adding a selector case
272+
273+
matchOrders specific notes:
274+
- Sponsoring is NOT supported. The requester (sender) always pays for the deal.
275+
- Clients must compute the exact deal cost and deposit the right amount.
276+
The deal cost = (appPrice + datasetPrice + workerpoolPrice) * volume._
269277

270278
#### Parameters
271279

272280
| Name | Type | Description |
273281
| ---- | ---- | ----------- |
274-
| sender | address | The address that approved tokens (must be requester if matching) |
282+
| sender | address | The address that approved tokens |
275283
| amount | uint256 | Amount of tokens approved and to be deposited |
276284
| token | address | Address of the token (must be RLC) |
277-
| data | bytes | Optional: ABI-encoded orders for matching |
285+
| data | bytes | Optional: Function selector + ABI-encoded parameters for operation |
278286

279287
#### Return Values
280288

281289
| Name | Type | Description |
282290
| ---- | ---- | ----------- |
283-
| [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); ``` |
291+
| [0] | bool | success True if operation succeeded @custom:example ```solidity // Compute deal cost uint256 dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume; // Encode matchOrders operation with selector bytes memory data = abi.encodeWithSelector( IexecPoco1.matchOrders.selector, appOrder, datasetOrder, workerpoolOrder, requestOrder ); // One transaction does it all: approve, deposit, and match RLC(token).approveAndCall(iexecProxy, dealCost, data); ``` |
284292

285293
## IexecOrderManagementFacet
286294

test/byContract/IexecEscrow/IexecEscrowToken.receiveApproval.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ describe('IexecEscrowToken-receiveApproval', () => {
144144
});
145145
});
146146

147-
describe('receiveApproval with Order Matching', () => {
148-
it('Should approve, deposit and match orders with all assets', async () => {
147+
describe('receiveApproval with generalized operation execution', () => {
148+
it('Should approve, deposit and execute matchOrders operation with all assets', async () => {
149149
const orders = buildOrders({
150150
assets: ordersAssets,
151151
prices: ordersPrices,
@@ -167,6 +167,7 @@ describe('IexecEscrowToken-receiveApproval', () => {
167167
const initialBalance = await iexecPoco.balanceOf(requester.address);
168168
const initialTotalSupply = await iexecPoco.totalSupply();
169169

170+
// Encode the matchOrders operation with its selector
170171
const encodedOrders = encodeOrdersForCallback(orders);
171172

172173
const tx = await rlcInstanceAsRequester.approveAndCall(
@@ -344,6 +345,19 @@ describe('IexecEscrowToken-receiveApproval', () => {
344345
).to.be.revertedWith('IexecEscrow: Transfer amount exceeds balance');
345346
});
346347

348+
it('Should revert with unsupported-operation for unknown function selector', async () => {
349+
const dealCost = 1000n;
350+
// Create calldata with an unsupported function selector (not matchOrders)
351+
// Using a random selector that doesn't exist
352+
const unsupportedSelector = '0x12345678';
353+
const dummyData = ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [42]);
354+
const invalidData = unsupportedSelector + dummyData.slice(2);
355+
356+
await expect(
357+
rlcInstanceAsRequester.approveAndCall(proxyAddress, dealCost, invalidData),
358+
).to.be.revertedWith('unsupported-operation');
359+
});
360+
347361
it('Should not match orders with invalid calldata', async () => {
348362
const dealCost = (appPrice + datasetPrice + workerpoolPrice) * volume;
349363
const invalidData = '0x1234'; // Too short to be valid
@@ -446,8 +460,10 @@ describe('IexecEscrowToken-receiveApproval', () => {
446460
expect(await iexecPoco.frozenOf(requester.address)).to.equal(0n);
447461
});
448462

449-
it('Should revert with receive-approval-failed when delegatecall fails silently', async () => {
463+
it('Should revert with operation-failed when delegatecall fails silently', async () => {
450464
// Deploy the mock helper contract that fails silently
465+
// This tests that the generalized _executeOperation properly handles
466+
// delegatecall failures and bubbles up errors
451467
const mockFacet = await new ReceiveApprovalTestHelper__factory()
452468
.connect(iexecAdmin)
453469
.deploy()
@@ -495,7 +511,7 @@ describe('IexecEscrowToken-receiveApproval', () => {
495511
depositAmount,
496512
encodedOrders,
497513
);
498-
await expect(tx).to.be.revertedWith('receive-approval-failed');
514+
await expect(tx).to.be.revertedWith('operation-failed');
499515

500516
// Restore original facet
501517
await diamondCut.diamondCut(

utils/odb-tools.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { TypedDataDomain, TypedDataEncoder, TypedDataField, ethers } from 'ethers';
55
import hre from 'hardhat';
6+
import { IexecPoco1Facet__factory } from '../typechain';
67

78
interface WalletInfo {
89
privateKey?: string;
@@ -169,13 +170,18 @@ export function hashStruct(
169170
}
170171

171172
/**
172-
* Encode orders for callback data in receiveApproval.
173-
* Uses typechain-generated struct definitions to ensure type consistency.
173+
* Encode orders with matchOrders selector for receiveApproval callback.
174+
*
175+
* The encoded data includes the function selector as the first 4 bytes, which allows
176+
* the generalized receiveApproval implementation to:
177+
* 1. Extract the selector to identify the operation (matchOrders in this case)
178+
* 2. Call the appropriate validator (_validateMatchOrders for permission checks)
179+
*
174180
* @param appOrder App order struct
175181
* @param datasetOrder Dataset order struct
176182
* @param workerpoolOrder Workerpool order struct
177183
* @param requestOrder Request order struct
178-
* @returns ABI-encoded orders
184+
* @returns ABI-encoded calldata with matchOrders selector + encoded order structs
179185
*/
180186
export function encodeOrders(
181187
appOrder: Record<string, any>,
@@ -195,8 +201,13 @@ export function encodeOrders(
195201
const requestOrderType =
196202
'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)';
197203

198-
return ethers.AbiCoder.defaultAbiCoder().encode(
204+
// Encode the function parameters (without selector)
205+
const encodedParams = ethers.AbiCoder.defaultAbiCoder().encode(
199206
[appOrderType, datasetOrderType, workerpoolOrderType, requestOrderType],
200207
[appOrder, datasetOrder, workerpoolOrder, requestOrder],
201208
);
209+
const matchOrdersSelector =
210+
IexecPoco1Facet__factory.createInterface().getFunction('matchOrders')!.selector;
211+
212+
return matchOrdersSelector + encodedParams.slice(2);
202213
}

0 commit comments

Comments
 (0)