Skip to content

Commit 26fa469

Browse files
authored
feat: Allow simulateUtility batching, better BatchCall (#17938)
We have to rethink and probably rename `BatchCall`, but he functionality implemented by this PR is sorely needed: - Allow multiple utilities to be sent to the wallet as a batch, instead of relying on `Promise.all` and `BatchCall` - Do not allow `BatchCall` to create an empty transaction along the utilities if no private/public calls are provided - Add tests
2 parents 7c12214 + feb335f commit 26fa469

File tree

5 files changed

+313
-37
lines changed

5 files changed

+313
-37
lines changed
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { ExecutionPayload } from '@aztec/entrypoints/payload';
2+
import { Fr } from '@aztec/foundation/fields';
3+
import { FunctionSelector, FunctionType } from '@aztec/stdlib/abi';
4+
import { AztecAddress } from '@aztec/stdlib/aztec-address';
5+
import { TxSimulationResult, UtilitySimulationResult } from '@aztec/stdlib/tx';
6+
7+
import { type MockProxy, mock } from 'jest-mock-extended';
8+
9+
import type { Wallet } from '../wallet/wallet.js';
10+
import { BatchCall } from './batch_call.js';
11+
12+
// eslint-disable-next-line jsdoc/require-jsdoc
13+
function createUtilityExecutionPayload(
14+
functionName: string,
15+
args: Fr[],
16+
contractAddress: AztecAddress,
17+
): ExecutionPayload {
18+
return new ExecutionPayload(
19+
[
20+
{
21+
name: functionName,
22+
to: contractAddress,
23+
selector: FunctionSelector.random(),
24+
type: FunctionType.UTILITY,
25+
isStatic: true,
26+
hideMsgSender: false,
27+
args,
28+
returnTypes: [{ kind: 'field' }],
29+
},
30+
],
31+
[],
32+
[],
33+
[],
34+
);
35+
}
36+
37+
// eslint-disable-next-line jsdoc/require-jsdoc
38+
function createPrivateExecutionPayload(
39+
functionName: string,
40+
args: Fr[],
41+
contractAddress: AztecAddress,
42+
numReturnValues: number = 2,
43+
): ExecutionPayload {
44+
return new ExecutionPayload(
45+
[
46+
{
47+
name: functionName,
48+
to: contractAddress,
49+
selector: FunctionSelector.random(),
50+
type: FunctionType.PRIVATE,
51+
isStatic: false,
52+
hideMsgSender: false,
53+
args,
54+
returnTypes: Array(numReturnValues).fill({ kind: 'field' }),
55+
},
56+
],
57+
[],
58+
[],
59+
[],
60+
);
61+
}
62+
63+
// eslint-disable-next-line jsdoc/require-jsdoc
64+
function createPublicExecutionPayload(
65+
functionName: string,
66+
args: Fr[],
67+
contractAddress: AztecAddress,
68+
): ExecutionPayload {
69+
return new ExecutionPayload(
70+
[
71+
{
72+
name: functionName,
73+
to: contractAddress,
74+
selector: FunctionSelector.random(),
75+
type: FunctionType.PUBLIC,
76+
isStatic: false,
77+
hideMsgSender: false,
78+
args,
79+
returnTypes: [{ kind: 'field' }],
80+
},
81+
],
82+
[],
83+
[],
84+
[],
85+
);
86+
}
87+
88+
describe('BatchCall', () => {
89+
let wallet: MockProxy<Wallet>;
90+
let batchCall: BatchCall;
91+
92+
beforeEach(() => {
93+
wallet = mock<Wallet>();
94+
});
95+
96+
describe('simulate with mixed interactions', () => {
97+
it('should batch utility calls using wallet.batch and simulate private/public calls', async () => {
98+
const contractAddress1 = await AztecAddress.random();
99+
const contractAddress2 = await AztecAddress.random();
100+
const contractAddress3 = await AztecAddress.random();
101+
102+
// Create mock payloads: 2 utility, 1 private, 1 public
103+
const utilityPayload1 = createUtilityExecutionPayload('getBalance', [Fr.random()], contractAddress1);
104+
const privatePayload = createPrivateExecutionPayload('transfer', [Fr.random(), Fr.random()], contractAddress2);
105+
const utilityPayload2 = createUtilityExecutionPayload('checkPermission', [Fr.random()], contractAddress3);
106+
const publicPayload = createPublicExecutionPayload('mint', [Fr.random()], contractAddress1);
107+
108+
batchCall = new BatchCall(wallet, [utilityPayload1, privatePayload, utilityPayload2, publicPayload]);
109+
110+
// Mock utility simulation results
111+
const utilityResult1 = UtilitySimulationResult.random();
112+
const utilityResult2 = UtilitySimulationResult.random();
113+
114+
wallet.batch.mockResolvedValue([
115+
{ name: 'simulateUtility', result: utilityResult1 },
116+
{ name: 'simulateUtility', result: utilityResult2 },
117+
] as any);
118+
119+
// Mock tx simulation result
120+
const privateReturnValues = [Fr.random(), Fr.random()];
121+
const publicReturnValues = [Fr.random()];
122+
123+
const txSimResult = mock<TxSimulationResult>();
124+
txSimResult.getPrivateReturnValues.mockReturnValue({
125+
nested: [{ values: privateReturnValues }],
126+
} as any);
127+
txSimResult.getPublicReturnValues.mockReturnValue([{ values: publicReturnValues }] as any);
128+
wallet.simulateTx.mockResolvedValue(txSimResult);
129+
130+
const results = await batchCall.simulate({ from: await AztecAddress.random() });
131+
132+
// Verify wallet.batch was called with both utility calls
133+
expect(wallet.batch).toHaveBeenCalledTimes(1);
134+
expect(wallet.batch).toHaveBeenCalledWith([
135+
{
136+
name: 'simulateUtility',
137+
args: ['getBalance', expect.any(Array), contractAddress1, undefined],
138+
},
139+
{
140+
name: 'simulateUtility',
141+
args: ['checkPermission', expect.any(Array), contractAddress3, undefined],
142+
},
143+
]);
144+
145+
// Verify wallet.simulateTx was called with merged private/public calls
146+
expect(wallet.simulateTx).toHaveBeenCalledTimes(1);
147+
expect(wallet.simulateTx).toHaveBeenCalledWith(
148+
expect.objectContaining({
149+
calls: expect.arrayContaining([
150+
expect.objectContaining({ type: FunctionType.PRIVATE }),
151+
expect.objectContaining({ type: FunctionType.PUBLIC }),
152+
]),
153+
}),
154+
expect.any(Object),
155+
);
156+
157+
expect(results).toHaveLength(4);
158+
expect(results[0]).toBe(utilityResult1.result); // First utility
159+
// Results[1] will be the decoded private values (decoded from privateReturnValues)
160+
expect(results[1]).toEqual(privateReturnValues.map(v => v.toBigInt())); // Private call (decoded)
161+
expect(results[2]).toBe(utilityResult2.result); // Second utility
162+
// Results[3] will be the decoded public value (single value is returned directly, not as array)
163+
expect(results[3]).toEqual(publicReturnValues[0].toBigInt()); // Public call (decoded)
164+
});
165+
166+
it('should handle only utility calls without calling simulateTx', async () => {
167+
const contractAddress1 = await AztecAddress.random();
168+
const contractAddress2 = await AztecAddress.random();
169+
170+
const utilityPayload1 = createUtilityExecutionPayload('view1', [], contractAddress1);
171+
const utilityPayload2 = createUtilityExecutionPayload('view2', [], contractAddress2);
172+
173+
batchCall = new BatchCall(wallet, [utilityPayload1, utilityPayload2]);
174+
175+
const utilityResult1 = UtilitySimulationResult.random();
176+
const utilityResult2 = UtilitySimulationResult.random();
177+
178+
wallet.batch.mockResolvedValue([
179+
{ name: 'simulateUtility', result: utilityResult1 },
180+
{ name: 'simulateUtility', result: utilityResult2 },
181+
] as any);
182+
183+
const results = await batchCall.simulate({ from: await AztecAddress.random() });
184+
185+
expect(wallet.batch).toHaveBeenCalledTimes(1);
186+
187+
// Verify wallet.simulateTx was NOT called since there are no private/public calls. This avoids empty txs.
188+
expect(wallet.simulateTx).not.toHaveBeenCalled();
189+
190+
// Verify results
191+
expect(results).toHaveLength(2);
192+
expect(results[0]).toBe(utilityResult1.result);
193+
expect(results[1]).toBe(utilityResult2.result);
194+
});
195+
196+
it('should handle only private/public calls without calling wallet.batch', async () => {
197+
const contractAddress1 = await AztecAddress.random();
198+
const contractAddress2 = await AztecAddress.random();
199+
200+
const privatePayload = createPrivateExecutionPayload('privateFunc', [Fr.random()], contractAddress1, 1);
201+
const publicPayload = createPublicExecutionPayload('publicFunc', [Fr.random()], contractAddress2);
202+
203+
batchCall = new BatchCall(wallet, [privatePayload, publicPayload]);
204+
205+
const privateReturnValues = [Fr.random()];
206+
const publicReturnValues = [Fr.random()];
207+
208+
const txSimResult = mock<TxSimulationResult>();
209+
txSimResult.getPrivateReturnValues.mockReturnValue({
210+
nested: [{ values: privateReturnValues }],
211+
} as any);
212+
txSimResult.getPublicReturnValues.mockReturnValue([{ values: publicReturnValues }] as any);
213+
wallet.simulateTx.mockResolvedValue(txSimResult);
214+
215+
const results = await batchCall.simulate({ from: await AztecAddress.random() });
216+
217+
// Verify wallet.batch was NOT called since there are no utility calls
218+
expect(wallet.batch).not.toHaveBeenCalled();
219+
220+
expect(wallet.simulateTx).toHaveBeenCalledTimes(1);
221+
222+
// Verify results (decoded)
223+
expect(results).toHaveLength(2);
224+
expect(results[0]).toEqual(privateReturnValues[0].toBigInt()); // Single value returned directly
225+
expect(results[1]).toEqual(publicReturnValues[0].toBigInt()); // Single value returned directly
226+
});
227+
228+
it('should handle empty batch', async () => {
229+
batchCall = new BatchCall(wallet, []);
230+
231+
const results = await batchCall.simulate({ from: await AztecAddress.random() });
232+
233+
expect(wallet.batch).not.toHaveBeenCalled();
234+
expect(wallet.simulateTx).not.toHaveBeenCalled();
235+
expect(results).toEqual([]);
236+
});
237+
});
238+
239+
describe('request', () => {
240+
it('should include fee payment method if provided', async () => {
241+
const contractAddress = await AztecAddress.random();
242+
const payload = createPrivateExecutionPayload('func', [Fr.random()], contractAddress);
243+
244+
batchCall = new BatchCall(wallet, [payload]);
245+
246+
const feePayload = createPrivateExecutionPayload('payFee', [Fr.random()], await AztecAddress.random());
247+
// eslint-disable-next-line jsdoc/require-jsdoc
248+
const mockPaymentMethod = mock<{ getExecutionPayload: () => Promise<ExecutionPayload> }>();
249+
mockPaymentMethod.getExecutionPayload.mockResolvedValue(feePayload);
250+
251+
const result = await batchCall.request({
252+
fee: { paymentMethod: mockPaymentMethod as any },
253+
});
254+
255+
// Should have fee payment call first, then the actual call
256+
expect(result.calls).toHaveLength(2);
257+
expect(result.calls[0]).toEqual(feePayload.calls[0]);
258+
expect(result.calls[1]).toEqual(payload.calls[0]);
259+
expect(mockPaymentMethod.getExecutionPayload).toHaveBeenCalledTimes(1);
260+
});
261+
});
262+
});

yarn-project/aztec.js/src/contract/batch_call.ts

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,18 @@ import {
1313
export class BatchCall extends BaseContractInteraction {
1414
constructor(
1515
wallet: Wallet,
16-
protected interactions: BaseContractInteraction[],
16+
protected interactions: (BaseContractInteraction | ExecutionPayload)[],
1717
) {
1818
super(wallet);
1919
}
2020

21-
/**
22-
* Creates a new instance with no actual calls. Useful for triggering a no-op.
23-
* @param wallet - The wallet to use for sending the batch call.
24-
*/
25-
public static empty(wallet: Wallet) {
26-
return new BatchCall(wallet, []);
27-
}
28-
2921
/**
3022
* Returns an execution request that represents this operation.
3123
* @param options - An optional object containing additional configuration for the request generation.
3224
* @returns An execution payload wrapped in promise.
3325
*/
3426
public async request(options: RequestInteractionOptions = {}): Promise<ExecutionPayload> {
35-
const requests = await this.getRequests();
27+
const requests = await this.getExecutionPayloads();
3628
const feeExecutionPayload = options.fee?.paymentMethod
3729
? await options.fee.paymentMethod.getExecutionPayload()
3830
: undefined;
@@ -52,7 +44,7 @@ export class BatchCall extends BaseContractInteraction {
5244
* @returns The result of the transaction as returned by the contract function.
5345
*/
5446
public async simulate(options: SimulateInteractionOptions): Promise<any> {
55-
const { indexedExecutionPayloads, utility } = (await this.getRequests()).reduce<{
47+
const { indexedExecutionPayloads, utility } = (await this.getExecutionPayloads()).reduce<{
5648
/** Keep track of the number of private calls to retrieve the return values */
5749
privateIndex: 0;
5850
/** Keep track of the number of public calls to retrieve the return values */
@@ -87,37 +79,49 @@ export class BatchCall extends BaseContractInteraction {
8779
combinedPayload.extraHashedArgs,
8880
);
8981

90-
const utilityCalls = utility.map(
91-
async ([call, index]) =>
92-
[await this.wallet.simulateUtility(call.name, call.args, call.to, options?.authWitnesses), index] as const,
93-
);
82+
const utilityBatchPromise =
83+
utility.length > 0
84+
? this.wallet.batch(
85+
utility.map(([call]) => ({
86+
name: 'simulateUtility' as const,
87+
args: [call.name, call.args, call.to, options?.authWitnesses] as const,
88+
})),
89+
)
90+
: Promise.resolve([]);
9491

95-
const [utilityResults, simulatedTx] = await Promise.all([
96-
Promise.all(utilityCalls),
97-
this.wallet.simulateTx(executionPayload, await toSimulateOptions(options)),
92+
const [utilityBatchResults, simulatedTx] = await Promise.all([
93+
utilityBatchPromise,
94+
indexedExecutionPayloads.length > 0
95+
? this.wallet.simulateTx(executionPayload, await toSimulateOptions(options))
96+
: Promise.resolve(),
9897
]);
9998

10099
const results: any[] = [];
101100

102-
utilityResults.forEach(([{ result }, index]) => {
103-
results[index] = result;
101+
utilityBatchResults.forEach((wrappedResult, utilityIndex) => {
102+
const [, originalIndex] = utility[utilityIndex];
103+
results[originalIndex] = wrappedResult.result.result;
104104
});
105-
indexedExecutionPayloads.forEach(([request, callIndex, resultIndex]) => {
106-
const call = request.calls[0];
107-
// As account entrypoints are private, for private functions we retrieve the return values from the first nested call
108-
// since we're interested in the first set of values AFTER the account entrypoint
109-
// For public functions we retrieve the first values directly from the public output.
110-
const rawReturnValues =
111-
call.type == FunctionType.PRIVATE
112-
? simulatedTx.getPrivateReturnValues()?.nested?.[resultIndex].values
113-
: simulatedTx.getPublicReturnValues()?.[resultIndex].values;
114105

115-
results[callIndex] = rawReturnValues ? decodeFromAbi(call.returnTypes, rawReturnValues) : [];
116-
});
106+
if (simulatedTx) {
107+
indexedExecutionPayloads.forEach(([request, callIndex, resultIndex]) => {
108+
const call = request.calls[0];
109+
// As account entrypoints are private, for private functions we retrieve the return values from the first nested call
110+
// since we're interested in the first set of values AFTER the account entrypoint
111+
// For public functions we retrieve the first values directly from the public output.
112+
const rawReturnValues =
113+
call.type == FunctionType.PRIVATE
114+
? simulatedTx.getPrivateReturnValues()?.nested?.[resultIndex].values
115+
: simulatedTx.getPublicReturnValues()?.[resultIndex].values;
116+
117+
results[callIndex] = rawReturnValues ? decodeFromAbi(call.returnTypes, rawReturnValues) : [];
118+
});
119+
}
120+
117121
return results;
118122
}
119123

120-
protected async getRequests() {
121-
return await Promise.all(this.interactions.map(i => i.request()));
124+
protected async getExecutionPayloads(): Promise<ExecutionPayload[]> {
125+
return await Promise.all(this.interactions.map(i => (i instanceof ExecutionPayload ? i : i.request())));
122126
}
123127
}

0 commit comments

Comments
 (0)