Skip to content

Commit d348f74

Browse files
authored
feat: snip29 safety checks (#1403)
* fix: add safeExecutePaymaster(to verify) * test: add tests suite for safeExecutePaymaster * fix: refactor, add comments, add interfaces function * fix: merge executePaymaster & safeExecutePaymaster * fix: refactor 'assertCallsAreStrictlyEqual' function & move it to utils * refactor: move safety checks to utils * fix: remove paymaster flow from standard 'execute' call * fix: remove paymaster flow from standard 'execute' call * fix: assert gasToken address in assertPaymasterTransactionSafety * fix: types were not correct for typedData calls
1 parent 07e8b4e commit d348f74

File tree

6 files changed

+462
-81
lines changed

6 files changed

+462
-81
lines changed

__tests__/accountPaymaster.test.ts

Lines changed: 195 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,55 @@
1-
import { Account, Signature, Call, PaymasterDetails, PaymasterRpc } from '../src';
1+
import { OutsideCallV2, OutsideExecutionTypedDataV2 } from 'starknet-types-08';
2+
import { Account, Signature, Call, PaymasterDetails, OutsideExecutionVersion } from '../src';
3+
import { getSelectorFromName } from '../src/utils/hash';
24

35
jest.mock('../src/paymaster/rpc');
46

57
describe('Account - Paymaster integration', () => {
8+
let account: Account | null = null;
69
const mockBuildTransaction = jest.fn();
10+
const mockMaliciousBuildTransactionChangeToken = jest.fn();
11+
const mockMaliciousBuildTransactionChangeFees = jest.fn();
12+
const mockMaliciousBuildTransactionAddedCalls = jest.fn();
713
const mockExecuteTransaction = jest.fn();
14+
const mockGetSnip9Version = jest.fn();
815
const mockSignMessage = jest.fn();
916

10-
const fakeTypedData = {
17+
const fakeSignature: Signature = ['0x1', '0x2'];
18+
const originalCalls: Call[] = [
19+
{ contractAddress: '0x123', entrypoint: 'transfer', calldata: [] },
20+
];
21+
22+
const originalCallsAsOutsideCalls: OutsideCallV2[] = [
23+
{
24+
To: '0x123',
25+
Selector: getSelectorFromName('transfer'),
26+
Calldata: [],
27+
},
28+
];
29+
30+
const typedData: OutsideExecutionTypedDataV2 = {
1131
types: {},
1232
domain: {},
1333
primaryType: '',
1434
message: {
15-
caller: '0xcaller',
16-
nonce: '0xnonce',
17-
execute_after: '0x1',
18-
execute_before: '0x2',
19-
calls_len: '0x0',
20-
calls: [],
35+
Caller: '0xcaller',
36+
Nonce: '0xnonce',
37+
'Execute After': '0x1',
38+
'Execute Before': '0x2',
39+
Calls: [
40+
...originalCallsAsOutsideCalls,
41+
{
42+
To: '0x456',
43+
Selector: getSelectorFromName('transfer'),
44+
Calldata: ['0xcaller', '1200', '0'],
45+
},
46+
],
2147
},
2248
};
2349

24-
const fakeSignature: Signature = ['0x1', '0x2'];
25-
const calls: Call[] = [{ contractAddress: '0x123', entrypoint: 'transfer', calldata: [] }];
26-
2750
const paymasterResponse = {
2851
type: 'invoke',
29-
typed_data: fakeTypedData,
52+
typed_data: typedData,
3053
parameters: {
3154
version: '0x1',
3255
feeMode: { mode: 'default', gasToken: '0x456' },
@@ -40,41 +63,109 @@ describe('Account - Paymaster integration', () => {
4063
},
4164
};
4265

43-
const mockPaymaster = () =>
44-
({
45-
buildTransaction: mockBuildTransaction,
46-
executeTransaction: mockExecuteTransaction,
47-
}) as unknown as PaymasterRpc;
48-
49-
const setupAccount = () =>
50-
new Account(
51-
{},
52-
'0xabc',
53-
{ signMessage: mockSignMessage.mockResolvedValue(fakeSignature) } as any,
54-
undefined,
55-
undefined,
56-
mockPaymaster()
57-
);
66+
const maliciousTypedDataChangeToken: OutsideExecutionTypedDataV2 = {
67+
...typedData,
68+
message: {
69+
...typedData.message,
70+
Calls: [
71+
...originalCallsAsOutsideCalls,
72+
{
73+
To: '0x4567',
74+
Selector: getSelectorFromName('transfer'),
75+
Calldata: ['0xcaller', '1200', '0'],
76+
},
77+
],
78+
},
79+
};
80+
81+
const maliciousTypedDataChangeFees: OutsideExecutionTypedDataV2 = {
82+
...typedData,
83+
message: {
84+
...typedData.message,
85+
Calls: [
86+
...originalCallsAsOutsideCalls,
87+
{
88+
To: '0x456',
89+
Selector: getSelectorFromName('transfer'),
90+
Calldata: ['0xcaller', '13000', '0'],
91+
},
92+
],
93+
},
94+
};
95+
96+
const maliciousTypedDataAddedCalls: OutsideExecutionTypedDataV2 = {
97+
...typedData,
98+
message: {
99+
...typedData.message,
100+
Calls: [
101+
...originalCallsAsOutsideCalls,
102+
{
103+
To: '0x456',
104+
Selector: getSelectorFromName('transfer'),
105+
Calldata: ['0xcaller', '13000', '0'],
106+
},
107+
{
108+
To: '0x456',
109+
Selector: getSelectorFromName('transfer'),
110+
Calldata: ['0xcaller', '13000', '0'],
111+
},
112+
],
113+
},
114+
};
115+
116+
const maliciousPaymasterResponseChangeToken = {
117+
...paymasterResponse,
118+
typed_data: maliciousTypedDataChangeToken,
119+
};
120+
const maliciousPaymasterResponseChangeFees = {
121+
...paymasterResponse,
122+
typed_data: maliciousTypedDataChangeFees,
123+
};
124+
const maliciousPaymasterResponseAddedCalls = {
125+
...paymasterResponse,
126+
typed_data: maliciousTypedDataAddedCalls,
127+
};
128+
129+
const getAccount = () => {
130+
if (!account) {
131+
account = new Account(
132+
{},
133+
'0xabc',
134+
{ signMessage: mockSignMessage.mockResolvedValue(fakeSignature) } as any,
135+
undefined,
136+
undefined,
137+
undefined
138+
);
139+
// account object is instanciate in the constructor, we need to mock the paymaster methods after paymaster object is instanciate
140+
account.paymaster.buildTransaction = mockBuildTransaction;
141+
account.paymaster.executeTransaction = mockExecuteTransaction;
142+
}
143+
return account;
144+
};
58145

59146
beforeEach(() => {
60147
jest.clearAllMocks();
61-
(PaymasterRpc as jest.Mock).mockImplementation(mockPaymaster);
148+
jest.spyOn(getAccount(), 'getSnip9Version').mockImplementation(mockGetSnip9Version);
62149
mockBuildTransaction.mockResolvedValue(paymasterResponse);
150+
mockMaliciousBuildTransactionChangeToken.mockResolvedValue(
151+
maliciousPaymasterResponseChangeToken
152+
);
153+
mockMaliciousBuildTransactionChangeFees.mockResolvedValue(maliciousPaymasterResponseChangeFees);
154+
mockMaliciousBuildTransactionAddedCalls.mockResolvedValue(maliciousPaymasterResponseAddedCalls);
63155
mockExecuteTransaction.mockResolvedValue({ transaction_hash: '0x123' });
156+
mockGetSnip9Version.mockResolvedValue(OutsideExecutionVersion.V2);
64157
});
65158

66159
describe('estimatePaymasterTransactionFee', () => {
67160
it('should return estimated transaction fee from paymaster', async () => {
68-
const account = setupAccount();
69-
70-
const result = await account.estimatePaymasterTransactionFee(calls, {
161+
const result = await getAccount().estimatePaymasterTransactionFee(originalCalls, {
71162
feeMode: { mode: 'default', gasToken: '0x456' },
72163
});
73164

74165
expect(mockBuildTransaction).toHaveBeenCalledWith(
75166
{
76167
type: 'invoke',
77-
invoke: { userAddress: '0xabc', calls },
168+
invoke: { userAddress: '0xabc', calls: originalCalls },
78169
},
79170
{
80171
version: '0x1',
@@ -88,33 +179,21 @@ describe('Account - Paymaster integration', () => {
88179
});
89180

90181
describe('executePaymasterTransaction', () => {
91-
it('should throw if token price exceeds maxPriceInStrk', async () => {
92-
const account = setupAccount();
93-
const details: PaymasterDetails = {
94-
feeMode: { mode: 'default', gasToken: '0x456' },
95-
};
96-
97-
await expect(account.executePaymasterTransaction(calls, details, 100n)).rejects.toThrow(
98-
'Gas token price is too high'
99-
);
100-
});
101-
102-
it('should sign and execute transaction via paymaster', async () => {
103-
const account = setupAccount();
182+
it('should sign and execute transaction via paymaster without checking gas fees', async () => {
104183
const details: PaymasterDetails = {
105184
feeMode: { mode: 'default', gasToken: '0x456' },
106185
};
107186

108-
const result = await account.executePaymasterTransaction(calls, details);
187+
const result = await getAccount().executePaymasterTransaction(originalCalls, details);
109188

110189
expect(mockBuildTransaction).toHaveBeenCalledTimes(1);
111-
expect(mockSignMessage).toHaveBeenCalledWith(fakeTypedData, '0xabc');
190+
expect(mockSignMessage).toHaveBeenCalledWith(typedData, '0xabc');
112191
expect(mockExecuteTransaction).toHaveBeenCalledWith(
113192
{
114193
type: 'invoke',
115194
invoke: {
116195
userAddress: '0xabc',
117-
typedData: fakeTypedData,
196+
typedData,
118197
signature: ['0x1', '0x2'],
119198
},
120199
},
@@ -126,5 +205,73 @@ describe('Account - Paymaster integration', () => {
126205
);
127206
expect(result).toEqual({ transaction_hash: '0x123' });
128207
});
208+
209+
it('should sign and execute transaction via paymaster', async () => {
210+
const details: PaymasterDetails = {
211+
feeMode: { mode: 'default', gasToken: '0x456' },
212+
};
213+
214+
const result = await getAccount().executePaymasterTransaction(
215+
originalCalls,
216+
details,
217+
'0x123456'
218+
);
219+
expect(result).toEqual({ transaction_hash: '0x123' });
220+
});
221+
222+
it('should not throw if token price exceeds maxPriceInGasToken but transaction is sponsored', async () => {
223+
const details: PaymasterDetails = {
224+
feeMode: { mode: 'sponsored' },
225+
};
226+
const result = await getAccount().executePaymasterTransaction(
227+
originalCalls,
228+
details,
229+
'0x123'
230+
);
231+
expect(result).toEqual({
232+
transaction_hash: '0x123',
233+
});
234+
});
235+
236+
it('should throw if token price exceeds maxPriceInGasToken', async () => {
237+
const details: PaymasterDetails = {
238+
feeMode: { mode: 'default', gasToken: '0x456' },
239+
};
240+
241+
await expect(
242+
getAccount().executePaymasterTransaction(originalCalls, details, '0x123')
243+
).rejects.toThrow('Gas token price is too high');
244+
});
245+
246+
it('should throw if Gas token value is not equal to the provided gas fees', async () => {
247+
const details: PaymasterDetails = {
248+
feeMode: { mode: 'default', gasToken: '0x456' },
249+
};
250+
getAccount().paymaster.buildTransaction = mockMaliciousBuildTransactionChangeFees;
251+
await expect(
252+
getAccount().executePaymasterTransaction(originalCalls, details, '0x123456')
253+
).rejects.toThrow('Gas token value is not equal to the provided gas fees');
254+
});
255+
256+
it('should throw if Gas token address is not equal to the provided gas token', async () => {
257+
const details: PaymasterDetails = {
258+
feeMode: { mode: 'default', gasToken: '0x456' },
259+
};
260+
getAccount().paymaster.buildTransaction = mockMaliciousBuildTransactionChangeToken;
261+
await expect(
262+
getAccount().executePaymasterTransaction(originalCalls, details, '0x123456')
263+
).rejects.toThrow('Gas token address is not equal to the provided gas token');
264+
});
265+
266+
it('should throw if provided calls are not strictly equal to the returned calls', async () => {
267+
const details: PaymasterDetails = {
268+
feeMode: { mode: 'default', gasToken: '0x456' },
269+
};
270+
getAccount().paymaster.buildTransaction = mockMaliciousBuildTransactionAddedCalls;
271+
272+
await expect(
273+
getAccount().executePaymasterTransaction(originalCalls, details, '0x123456')
274+
).rejects.toThrow('Provided calls are not strictly equal to the returned calls');
275+
});
129276
});
130277
});

0 commit comments

Comments
 (0)