Skip to content

Commit 2d197c9

Browse files
committed
feat: flatten nested redeemDelegations into outer call
When a nested transaction in a delegated transaction is itself a redeemDelegations call, merge its permission contexts, modes, and calldatas into the outer redeemDelegations call as additional slots rather than wrapping it inside another delegation. This avoids the gas cost of an extra delegation layer when the nested call is already self-authorised.
1 parent 0e8493c commit 2d197c9

3 files changed

Lines changed: 342 additions & 26 deletions

File tree

app/core/Delegation/delegation.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -261,33 +261,37 @@ export const encodeDisableDelegation = ({
261261
return concat([encodedSignature, encodedData]);
262262
};
263263

264-
/**
265-
* Encodes the calldata for a redeemDelegations(delegations,modes,executions) call.
266-
*
267-
* @param params
268-
* @param params.delegations - The delegations to redeem.
269-
* @param params.modes - The modes to redeem the delegations with.
270-
* @param params.executions - The executions to redeem the delegations with.
271-
* @returns The encoded calldata.
272-
*/
273264
export const encodeRedeemDelegations = ({
274265
delegations,
275266
modes,
276267
executions,
268+
extraContexts = [],
269+
extraModes = [],
270+
extraCalldatas = [],
277271
}: {
278272
delegations: Delegation[][];
279273
modes: ExecutionMode[];
280274
executions: ExecutionStruct[][];
275+
extraContexts?: Hex[];
276+
extraModes?: Hex[];
277+
extraCalldatas?: Hex[];
281278
}) => {
282279
const encodedSignature = toFunctionSelector(
283280
'redeemDelegations(bytes[],bytes32[],bytes[])',
284281
);
285282

286-
const contexts = encodePermissionContexts(delegations);
287-
const calldatas = encodeExecutionCalldatas(executions);
283+
const contexts = [...encodePermissionContexts(delegations), ...extraContexts];
284+
const calldatas = [
285+
...(executions.length > 0 ? encodeExecutionCalldatas(executions) : []),
286+
...extraCalldatas,
287+
];
288+
const allModes = [...modes, ...extraModes];
288289

289290
const encodedData = toHex(
290-
encode(['bytes[]', 'bytes32[]', 'bytes[]'], [contexts, modes, calldatas]),
291+
encode(
292+
['bytes[]', 'bytes32[]', 'bytes[]'],
293+
[contexts, allModes, calldatas],
294+
),
291295
);
292296

293297
return concat([encodedSignature, encodedData]);

app/util/transactions/delegation.test.ts

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1+
import { decode } from '@metamask/abi-utils';
12
import {
23
IsAtomicBatchSupportedRequest,
34
TransactionController,
45
TransactionMeta,
56
} from '@metamask/transaction-controller';
67
import { SignMessenger, getDelegationTransaction } from './delegation';
78
import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger';
8-
import { Hex } from '@metamask/utils';
9+
import { Hex, bytesToHex, remove0x } from '@metamask/utils';
10+
import {
11+
BATCH_DEFAULT_MODE,
12+
SINGLE_DEFAULT_MODE,
13+
getDeleGatorEnvironment,
14+
} from '../../core/Delegation';
15+
import {
16+
REDEEM_DELEGATIONS_SELECTOR,
17+
encodeRedeemDelegations,
18+
} from '../../core/Delegation/delegation';
919

1020
const mockGetNonceLock = jest.fn();
1121

@@ -178,5 +188,181 @@ describe('Transaction Delegation Utils', () => {
178188
getDelegationTransaction(messengerMock, TRANSACTION_META_MOCK),
179189
).rejects.toThrow('Upgrade contract address not found');
180190
});
191+
192+
describe('nested redeemDelegations flattening', () => {
193+
const MAINNET_DELEGATION_MANAGER = getDeleGatorEnvironment(
194+
1,
195+
).DelegationManager.toLowerCase() as Hex;
196+
197+
const INNER_CONTEXT = '0xaabb' as Hex;
198+
const INNER_CALLDATA = '0xccdd' as Hex;
199+
200+
const buildInnerRedeemCalldata = (): Hex =>
201+
encodeRedeemDelegations({
202+
delegations: [],
203+
modes: [],
204+
executions: [],
205+
extraContexts: [INNER_CONTEXT],
206+
extraModes: [SINGLE_DEFAULT_MODE],
207+
extraCalldatas: [INNER_CALLDATA],
208+
});
209+
210+
const decodeOuter = (
211+
outerData: Hex,
212+
): { contexts: Hex[]; modes: Hex[]; calldatas: Hex[] } => {
213+
const payload = `0x${remove0x(outerData).slice(8)}` as Hex;
214+
const [contexts, modes, calldatas] = decode(
215+
['bytes[]', 'bytes32[]', 'bytes[]'],
216+
payload,
217+
) as [Uint8Array[], Uint8Array[], Uint8Array[]];
218+
return {
219+
contexts: contexts.map(bytesToHex),
220+
modes: modes.map(bytesToHex),
221+
calldatas: calldatas.map(bytesToHex),
222+
};
223+
};
224+
225+
it('flattens an inner redeemDelegations into additional outer slots', async () => {
226+
const innerRedeem = buildInnerRedeemCalldata();
227+
const regularTx = {
228+
data: '0xdeadbeef' as Hex,
229+
to: '0x1111111111111111111111111111111111111111' as Hex,
230+
};
231+
232+
const result = await getDelegationTransaction(messengerMock, {
233+
...TRANSACTION_META_MOCK,
234+
nestedTransactions: [
235+
{
236+
data: innerRedeem,
237+
to: MAINNET_DELEGATION_MANAGER,
238+
},
239+
regularTx,
240+
],
241+
} as TransactionMeta);
242+
243+
const outer = decodeOuter(result.data);
244+
245+
expect(outer.contexts).toHaveLength(2);
246+
expect(outer.modes).toHaveLength(2);
247+
expect(outer.calldatas).toHaveLength(2);
248+
expect(outer.contexts[1]).toBe(INNER_CONTEXT);
249+
expect(outer.modes[1]).toBe(SINGLE_DEFAULT_MODE);
250+
expect(outer.calldatas[1]).toBe(INNER_CALLDATA);
251+
});
252+
253+
it('signs the outer delegation only over the non-redeem nested transactions', async () => {
254+
const innerRedeem = buildInnerRedeemCalldata();
255+
const regularTx = {
256+
data: '0xdeadbeef' as Hex,
257+
to: '0x1111111111111111111111111111111111111111' as Hex,
258+
};
259+
260+
await getDelegationTransaction(messengerMock, {
261+
...TRANSACTION_META_MOCK,
262+
nestedTransactions: [
263+
{ data: innerRedeem, to: MAINNET_DELEGATION_MANAGER },
264+
regularTx,
265+
],
266+
} as TransactionMeta);
267+
268+
expect(signDelegationMock).toHaveBeenCalledTimes(1);
269+
const [signCall] = signDelegationMock.mock.calls;
270+
const signedDelegation = signCall[0].delegation;
271+
expect(signedDelegation.caveats).toHaveLength(2);
272+
});
273+
274+
it('uses SINGLE mode when only one regular nested transaction remains after flattening', async () => {
275+
const innerRedeem = buildInnerRedeemCalldata();
276+
const regularTx = {
277+
data: '0xdeadbeef' as Hex,
278+
to: '0x1111111111111111111111111111111111111111' as Hex,
279+
};
280+
281+
const result = await getDelegationTransaction(messengerMock, {
282+
...TRANSACTION_META_MOCK,
283+
nestedTransactions: [
284+
{ data: innerRedeem, to: MAINNET_DELEGATION_MANAGER },
285+
regularTx,
286+
],
287+
} as TransactionMeta);
288+
289+
const outer = decodeOuter(result.data);
290+
expect(outer.modes[0]).toBe(SINGLE_DEFAULT_MODE);
291+
});
292+
293+
it('does not flatten nested redeemDelegations when target is not the DelegationManager', async () => {
294+
const innerRedeem = buildInnerRedeemCalldata();
295+
const result = await getDelegationTransaction(messengerMock, {
296+
...TRANSACTION_META_MOCK,
297+
nestedTransactions: [
298+
{
299+
data: innerRedeem,
300+
to: '0x2222222222222222222222222222222222222222' as Hex,
301+
},
302+
],
303+
} as TransactionMeta);
304+
305+
const outer = decodeOuter(result.data);
306+
expect(outer.contexts).toHaveLength(1);
307+
expect(signDelegationMock).toHaveBeenCalledTimes(1);
308+
});
309+
310+
it('does not flatten when nested tx data does not start with the redeemDelegations selector', async () => {
311+
const result = await getDelegationTransaction(messengerMock, {
312+
...TRANSACTION_META_MOCK,
313+
nestedTransactions: [
314+
{
315+
data: '0xdeadbeef' as Hex,
316+
to: MAINNET_DELEGATION_MANAGER,
317+
},
318+
],
319+
} as TransactionMeta);
320+
321+
const outer = decodeOuter(result.data);
322+
expect(outer.contexts).toHaveLength(1);
323+
});
324+
325+
it('passes through inner contexts unchanged when only inner redeemDelegations are present', async () => {
326+
const innerRedeem = buildInnerRedeemCalldata();
327+
const result = await getDelegationTransaction(messengerMock, {
328+
...TRANSACTION_META_MOCK,
329+
nestedTransactions: [
330+
{ data: innerRedeem, to: MAINNET_DELEGATION_MANAGER },
331+
],
332+
} as TransactionMeta);
333+
334+
const outer = decodeOuter(result.data);
335+
expect(outer.contexts).toEqual([INNER_CONTEXT]);
336+
expect(outer.modes).toEqual([SINGLE_DEFAULT_MODE]);
337+
expect(outer.calldatas).toEqual([INNER_CALLDATA]);
338+
expect(signDelegationMock).not.toHaveBeenCalled();
339+
});
340+
341+
it('selects the correct selector for a redeemDelegations nested tx', () => {
342+
expect(REDEEM_DELEGATIONS_SELECTOR).toBe('0xcef6d209');
343+
});
344+
345+
it('uses BATCH mode when multiple regular nested transactions remain after flattening', async () => {
346+
const innerRedeem = buildInnerRedeemCalldata();
347+
const result = await getDelegationTransaction(messengerMock, {
348+
...TRANSACTION_META_MOCK,
349+
nestedTransactions: [
350+
{ data: innerRedeem, to: MAINNET_DELEGATION_MANAGER },
351+
{
352+
data: '0xdeadbeef' as Hex,
353+
to: '0x1111111111111111111111111111111111111111' as Hex,
354+
},
355+
{
356+
data: '0xfeedface' as Hex,
357+
to: '0x3333333333333333333333333333333333333333' as Hex,
358+
},
359+
],
360+
} as TransactionMeta);
361+
362+
const outer = decodeOuter(result.data);
363+
expect(outer.modes[0]).toBe(BATCH_DEFAULT_MODE);
364+
expect(outer.contexts).toHaveLength(2);
365+
});
366+
});
181367
});
182368
});

0 commit comments

Comments
 (0)