Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions __tests__/accountPaymaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
import {
Account,
OutsideExecutionVersion,
cairo,
constants,
logger,
hash,
Expand Down Expand Up @@ -430,6 +431,43 @@ describe('Account - Paymaster integration', () => {
expect(result).toEqual({ transaction_hash: '0x123' });
});

test('should sign and execute transaction via paymaster when a call holds a u256 object', async () => {
// A u256 can be written as an object, like cairo.uint256(1000). The paymaster gets it
// flattened, so the safety check must flatten it the same way before it rebuilds the hash.
const callsWithU256: Call[] = [
{
contractAddress: '0x123',
entrypoint: 'transfer',
calldata: ['0x789', cairo.uint256(1000)],
},
];
const typedDataWithU256: OutsideExecutionTypedDataV2 = {
...typedData,
message: {
...typedData.message,
Calls: [
{
To: '0x123',
Selector: hash.getSelectorFromName('transfer'),
Calldata: ['0x789', '1000', '0'],
},
typedData.message.Calls[1],
],
},
};
mockBuildTransaction.mockResolvedValueOnce({
...paymasterResponse,
typed_data: typedDataWithU256,
});
const details: PaymasterDetails = {
feeMode: { mode: 'default', gasToken: '0x456' },
};

const result = await getAccount().executePaymasterTransaction(callsWithU256, details);

expect(result).toEqual({ transaction_hash: '0x123' });
});

test('should not throw if token price exceeds maxPriceInGasToken but transaction is sponsored', async () => {
const details: PaymasterDetails = {
feeMode: { mode: 'sponsored' },
Expand Down
71 changes: 71 additions & 0 deletions __tests__/pluginPaymaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,75 @@ describe('Paymaster plugin', () => {
})
).rejects.toThrow('This account has no paymaster methods');
});

describe('contract methods with a paymaster option', () => {
// An ERC-20 transfer: once compiled with the abi, the u256 amount is two felts.
const erc20Abi: Abi = [
{
type: 'struct',
name: 'core::integer::u256',
members: [
{ name: 'low', type: 'core::integer::u128' },
{ name: 'high', type: 'core::integer::u128' },
],
},
{
type: 'function',
name: 'transfer',
inputs: [
{ name: 'recipient', type: 'core::starknet::contract_address::ContractAddress' },
{ name: 'amount', type: 'core::integer::u256' },
],
outputs: [{ type: 'core::bool' }],
state_mutability: 'external',
},
];
const paymasterDetails: PaymasterDetails = { feeMode: { mode: 'sponsored' } };

test('contract.invoke sends the calldata compiled with the abi', async () => {
const account = new Account({ provider, address, signer });
const executeSpy = jest
.spyOn(account, 'executePaymasterTransaction')
.mockResolvedValue({ transaction_hash: '0x1' });
const contract = new Contract({
abi: erc20Abi,
address: '0x123',
providerOrAccount: account,
});

await contract.invoke('transfer', ['0x456', 100], { paymasterDetails });

expect(executeSpy).toHaveBeenCalledWith(
[{ contractAddress: '0x123', entrypoint: 'transfer', calldata: ['1110', '100', '0'] }],
paymasterDetails,
undefined
);
});

test('contract.estimate sends the calldata compiled with the abi', async () => {
const fee: PaymasterFeeEstimate = {
gas_token_price_in_strk: '0x1',
estimated_fee_in_strk: '0x2',
estimated_fee_in_gas_token: '0x3',
suggested_max_fee_in_strk: '0x4',
suggested_max_fee_in_gas_token: '0x5',
};
const account = new Account({ provider, address, signer });
const estimateSpy = jest
.spyOn(account, 'estimatePaymasterTransactionFee')
.mockResolvedValue(fee);
const contract = new Contract({
abi: erc20Abi,
address: '0x123',
providerOrAccount: account,
});

await contract.estimate('transfer', ['0x456', 100], { paymasterDetails });

expect(estimateSpy).toHaveBeenCalledWith(
[{ contractAddress: '0x123', entrypoint: 'transfer', calldata: ['1110', '100', '0'] }],
paymasterDetails
);
});
});
});
72 changes: 72 additions & 0 deletions __tests__/utils/outsideExecution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
CallData,
OutsideExecutionVersion,
cairo,
constants,
outsideExecution,
typedData,
type Call,
} from '../../src';

describe('outsideExecution.getOutsideCall', () => {
test('flattens a u256 object in an array calldata', () => {
const call: Call = {
contractAddress: '0x123',
entrypoint: 'transfer',
calldata: ['0x456', cairo.uint256(100)],
};

expect(outsideExecution.getOutsideCall(call).calldata).toEqual(['1110', '100', '0']);
});

test('keeps the values of a flat array calldata, written in decimal', () => {
const call: Call = {
contractAddress: '0x123',
entrypoint: 'transfer',
calldata: ['0x456', '0x64', '0x0'],
};

expect(outsideExecution.getOutsideCall(call).calldata).toEqual(['1110', '100', '0']);
});

test('keeps a calldata marked as compiled as it is', () => {
const calldata = CallData.compile(['0x456', cairo.uint256(100)]);
const call: Call = { contractAddress: '0x123', entrypoint: 'transfer', calldata };

expect(outsideExecution.getOutsideCall(call).calldata).toBe(calldata);
});
});

describe('outsideExecution.getTypedData', () => {
test('gives the same hash for a u256 written as an object or as two felts', () => {
const options = {
caller: constants.OutsideExecutionCallerAny,
execute_after: 1,
execute_before: 1_800_000_000,
};
const withObject = outsideExecution.getTypedData(
constants.StarknetChainId.SN_SEPOLIA,
options,
1,
[
{
contractAddress: '0x123',
entrypoint: 'transfer',
calldata: ['0x456', cairo.uint256(100)],
},
],
OutsideExecutionVersion.V2
);
const withFelts = outsideExecution.getTypedData(
constants.StarknetChainId.SN_SEPOLIA,
options,
1,
[{ contractAddress: '0x123', entrypoint: 'transfer', calldata: ['0x456', '100', '0'] }],
OutsideExecutionVersion.V2
);

expect(typedData.getMessageHash(withObject, '0xabc')).toBe(
typedData.getMessageHash(withFelts, '0xabc')
);
});
});
16 changes: 4 additions & 12 deletions src/contract/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,13 +392,9 @@ export class Contract implements ContractInterface {
typeof this.providerOrAccount.executePaymasterTransaction === 'function',
NO_PAYMASTER_MESSAGE
);
const myCall: Call = {
contractAddress: this.address,
entrypoint: method,
calldata: args,
};
// Send the calldata compiled with the abi, as execute() does below, not the raw arguments.
return this.providerOrAccount.executePaymasterTransaction(
[myCall],
[invocation],
restInvokeOptions.paymasterDetails,
restInvokeOptions.maxFeeInGasToken
);
Expand Down Expand Up @@ -456,13 +452,9 @@ export class Contract implements ContractInterface {
typeof this.providerOrAccount.estimatePaymasterTransactionFee === 'function',
NO_PAYMASTER_MESSAGE
);
const myCall: Call = {
contractAddress: this.address,
entrypoint: method,
calldata: args,
};
// Send the calldata compiled with the abi (see populate), not the raw arguments.
return this.providerOrAccount.estimatePaymasterTransactionFee(
[myCall],
[invocation],
estimateDetails.paymasterDetails
);
}
Expand Down
7 changes: 6 additions & 1 deletion src/utils/outsideExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export function toOutsideCallV2(call: OutsideCallV1 | OutsideCallV2): OutsideCal

/**
* Converts a Call object to an OutsideCall object that can be used for an Outside Execution.
* The calldata follows the rule of `account.execute()`: a calldata marked as compiled is kept as
* it is, and any other calldata is a list or an object of parameters, which is flattened.
* @param {Call} call transaction to proceed.
* @returns {OutsideCall} transaction formatted in conformity to SNIP-9
* @example
Expand All @@ -46,7 +48,10 @@ export function toOutsideCallV2(call: OutsideCallV1 | OutsideCallV2): OutsideCal
*/
export function getOutsideCall(call: Call): OutsideCall {
const callData = call.calldata ?? [];
const callDataCompiled = Array.isArray(callData) ? callData : CallData.compile(callData);
// Same rule as fromCallsToExecuteCalldata_cairo1: an array without the compiled mark is a list
// of parameters, and a parameter can be an object, like a u256.
const callDataCompiled =
Array.isArray(callData) && '__compiled__' in callData ? callData : CallData.compile(callData);
return {
to: call.contractAddress,
selector: getSelectorFromName(call.entrypoint),
Expand Down
Loading