From 60b167b46fb299315968f1ef51ae4aff4f564ad5 Mon Sep 17 00:00:00 2001 From: PhilippeR26 Date: Mon, 11 Aug 2025 15:35:43 +0200 Subject: [PATCH] feat: paymaster snip-29 in Contract class --- __tests__/config/fixturesInit.ts | 5 +- __tests__/contractPaymaster.test.ts | 93 +++++++++++++++++++++++++++ src/contract/default.ts | 39 ++++++++--- src/contract/interface.ts | 3 +- src/contract/types/index.type.ts | 4 +- www/docs/guides/account/paymaster.md | 34 ++++++++++ www/docs/guides/contracts/interact.md | 5 +- 7 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 __tests__/contractPaymaster.test.ts diff --git a/__tests__/config/fixturesInit.ts b/__tests__/config/fixturesInit.ts index 00c1c7f79..607306575 100644 --- a/__tests__/config/fixturesInit.ts +++ b/__tests__/config/fixturesInit.ts @@ -5,6 +5,7 @@ import { RpcProvider, config, getTipStatsFromBlocks, + type PaymasterInterface, type TipAnalysisOptions, } from '../../src'; import { RpcProviderOptions, type BlockIdentifier } from '../../src/types'; @@ -58,7 +59,8 @@ export function adaptAccountIfDevnet(account: Account): Account { export const getTestAccount = ( provider: ProviderInterface, - txVersion?: SupportedTransactionVersion + txVersion?: SupportedTransactionVersion, + paymasterSnip29?: PaymasterInterface ) => { return adaptAccountIfDevnet( new Account({ @@ -66,6 +68,7 @@ export const getTestAccount = ( address: toHex(process.env.TEST_ACCOUNT_ADDRESS || ''), signer: process.env.TEST_ACCOUNT_PRIVATE_KEY || '', transactionVersion: txVersion ?? TEST_TX_VERSION, + paymaster: paymasterSnip29, }) ); }; diff --git a/__tests__/contractPaymaster.test.ts b/__tests__/contractPaymaster.test.ts new file mode 100644 index 000000000..1d32699b3 --- /dev/null +++ b/__tests__/contractPaymaster.test.ts @@ -0,0 +1,93 @@ +import { + type RpcProvider, + type Account, + Contract, + PaymasterRpc, + OutsideExecutionVersion, + type TokenData, + num, + type PaymasterDetails, + cairo, + type PaymasterFeeEstimate, +} from '../src'; +import { describeIfTestnet, getTestProvider } from './config/fixtures'; +import { getTestAccount, STRKtokenAddress } from './config/fixturesInit'; + +describeIfTestnet('Paymaster with Contract, in Testnet', () => { + let provider: RpcProvider; + let myAccount: Account; + let strkContract: Contract; + const feesDetails: PaymasterDetails = { + feeMode: { mode: 'default', gasToken: STRKtokenAddress }, + }; + + beforeAll(async () => { + provider = getTestProvider(false); + const paymasterRpc = new PaymasterRpc({ nodeUrl: 'https://sepolia.paymaster.avnu.fi' }); + myAccount = getTestAccount(provider, undefined, paymasterRpc); + // console.log(myAccount.paymaster); + const isAccountCompatibleSnip9 = await myAccount.getSnip9Version(); + expect(isAccountCompatibleSnip9).not.toBe(OutsideExecutionVersion.UNSUPPORTED); + const isPaymasterAvailable = await myAccount.paymaster.isAvailable(); + expect(isPaymasterAvailable).toBe(true); + strkContract = new Contract({ + abi: (await provider.getClassAt(STRKtokenAddress)).abi, + address: STRKtokenAddress, + providerOrAccount: myAccount, + }); + }); + + test('Get list of tokens', async () => { + const supported: TokenData[] = await myAccount.paymaster.getSupportedTokens(); + const containsStrk = supported.some( + (data: TokenData) => data.token_address === num.cleanHex(STRKtokenAddress) + ); + expect(containsStrk).toBe(true); + }); + + test('Estimate fee with Paymaster in a Contract', async () => { + const estimation = (await strkContract.estimate( + 'transfer', + [ + '0x010101', // random address + cairo.uint256(10), // dust of STRK + ], + { + paymasterDetails: feesDetails, + } + )) as PaymasterFeeEstimate; + expect(estimation.suggested_max_fee_in_gas_token).toBeDefined(); + }); + + test('Contract invoke with Paymaster', async () => { + const res1 = await strkContract.invoke('transfer', ['0x010101', cairo.uint256(100)], { + paymasterDetails: feesDetails, + }); + const txR1 = await provider.waitForTransaction(res1.transaction_hash); + expect(txR1.isSuccess()).toBe(true); + const res2 = await strkContract.invoke('transfer', ['0x010101', cairo.uint256(101)], { + paymasterDetails: feesDetails, + maxFeeInGasToken: 2n * 10n ** 17n, + }); + const txR2 = await provider.waitForTransaction(res2.transaction_hash); + expect(txR2.isSuccess()).toBe(true); + }); + + test('Contract withOptions with Paymaster', async () => { + const res1 = await strkContract + .withOptions({ + paymasterDetails: feesDetails, + }) + .transfer('0x010101', cairo.uint256(102)); + const txR1 = await provider.waitForTransaction(res1.transaction_hash); + expect(txR1.isSuccess()).toBe(true); + const res2 = await strkContract + .withOptions({ + paymasterDetails: feesDetails, + maxFeeInGasToken: 2n * 10n ** 17n, + }) + .transfer('0x010101', cairo.uint256(103)); + const txR2 = await provider.waitForTransaction(res2.transaction_hash); + expect(txR2.isSuccess()).toBe(true); + }); +}); diff --git a/src/contract/default.ts b/src/contract/default.ts index be536c4bd..d922b7b3b 100644 --- a/src/contract/default.ts +++ b/src/contract/default.ts @@ -26,6 +26,7 @@ import { FactoryParams, UniversalDetails, DeclareAndDeployContractPayload, + type PaymasterFeeEstimate, } from '../types'; import type { AccountInterface } from '../account/interface'; import assert from '../utils/assert'; @@ -270,7 +271,7 @@ export class Contract implements ContractInterface { public invoke( method: string, args: ArgsOrCalldata = [], - { parseRequest = true, signature, ...RestInvokeOptions }: ExecuteOptions = {} + { parseRequest = true, signature, ...restInvokeOptions }: ExecuteOptions = {} ): Promise { assert(this.address !== null, 'contract is not connected to an address'); @@ -289,12 +290,24 @@ export class Contract implements ContractInterface { entrypoint: method, }; if (isAccount(this.providerOrAccount)) { + if (restInvokeOptions.paymasterDetails) { + const myCall: Call = { + contractAddress: this.address, + entrypoint: method, + calldata: args, + }; + return this.providerOrAccount.executePaymasterTransaction( + [myCall], + restInvokeOptions.paymasterDetails, + restInvokeOptions.maxFeeInGasToken + ); + } return this.providerOrAccount.execute(invocation, { - ...RestInvokeOptions, + ...restInvokeOptions, }); } - if (!RestInvokeOptions.nonce) + if (!restInvokeOptions.nonce) throw new Error(`Manual nonce is required when invoking a function without an account`); logger.warn(`Invoking ${method} without an account.`); @@ -304,8 +317,8 @@ export class Contract implements ContractInterface { signature, }, { - ...RestInvokeOptions, - nonce: RestInvokeOptions.nonce, + ...restInvokeOptions, + nonce: restInvokeOptions.nonce, } ); } @@ -313,16 +326,26 @@ export class Contract implements ContractInterface { public async estimate( method: string, args: ArgsOrCalldata = [], - estimateDetails: UniversalDetails = {} - ): Promise { + estimateDetails: ExecuteOptions = {} + ): Promise { assert(this.address !== null, 'contract is not connected to an address'); if (!getCompiledCalldata(args, () => false)) { this.callData.validate(ValidateType.INVOKE, method, args); } - const invocation = this.populate(method, args); if (isAccount(this.providerOrAccount)) { + if (estimateDetails.paymasterDetails) { + const myCall: Call = { + contractAddress: this.address, + entrypoint: method, + calldata: args, + }; + return this.providerOrAccount.estimatePaymasterTransactionFee( + [myCall], + estimateDetails.paymasterDetails + ); + } return this.providerOrAccount.estimateInvokeFee(invocation, estimateDetails); } throw Error('Contract must be connected to the account contract to estimate'); diff --git a/src/contract/interface.ts b/src/contract/interface.ts index cd217cb69..cdd4d0b0f 100644 --- a/src/contract/interface.ts +++ b/src/contract/interface.ts @@ -8,6 +8,7 @@ import type { ContractVersion, Invocation, InvokeFunctionResponse, + PaymasterFeeEstimate, RawArgs, Uint256, } from '../types'; @@ -193,7 +194,7 @@ export abstract class ContractInterface { options?: { blockIdentifier?: BlockIdentifier; } - ): Promise; + ): Promise; /** * Populate transaction data for a contract method call diff --git a/src/contract/types/index.type.ts b/src/contract/types/index.type.ts index a3f0af667..7e1f5dbe8 100644 --- a/src/contract/types/index.type.ts +++ b/src/contract/types/index.type.ts @@ -11,7 +11,7 @@ import type { RawArgsArray, Signature, } from '../../types/lib'; -import type { UniversalDetails } from '../../account/types/index.type'; +import type { PaymasterDetails, UniversalDetails } from '../../account/types/index.type'; import type { ProviderInterface } from '../../provider'; import type { AccountInterface } from '../../account/interface'; @@ -97,6 +97,8 @@ export type ExecuteOptions = Pick & { * Deployer contract salt */ salt?: string; + paymasterDetails?: PaymasterDetails; + maxFeeInGasToken?: BigNumberish; } & Partial; export type CallOptions = CommonContractOptions & { diff --git a/www/docs/guides/account/paymaster.md b/www/docs/guides/account/paymaster.md index 4bed5b758..2fc166ea7 100644 --- a/www/docs/guides/account/paymaster.md +++ b/www/docs/guides/account/paymaster.md @@ -110,6 +110,40 @@ const res = await myAccount.executePaymasterTransaction( const txR = await myProvider.waitForTransaction(res.transaction_hash); ``` +### Paymaster transaction using Contract class + +```typescript +const gasToken = '0x53b40a647cedfca6ca84f542a0fe36736031905a9639a7f19a3c1e66bfd5080'; // USDC in Testnet +const feesDetails: PaymasterDetails = { + feeMode: { mode: 'default', gasToken }, +}; +const tokenContract = new Contract({ + abi: erc20Sierra.abi, + address: tokenAddress, + providerOrAccount: myAccount, +}); + +const feeEstimation = (await tokenContract.estimate( + 'transfer', + [destinationAddress, cairo.uint256(100)], + { paymasterDetails: feesDetails } +)) as PaymasterFeeEstimate; +// ask here to the user to accept this fee +const res1 = await tokenContract.invoke('transfer', [destinationAddress, cairo.uint256(100)], { + paymasterDetails: feesDetails, + maxFeeInGasToken: feeEstimation.suggested_max_fee_in_gas_token, +}); +const txR1 = await myProvider.waitForTransaction(res1.transaction_hash); +// or +const res2 = await myTestContract + .withOptions({ + paymasterDetails: feesDetails, + maxFeeInGasToken: feeEstimation.suggested_max_fee_in_gas_token, + }) + .transfer(destinationAddress, cairo.uint256(100)); +const txR2 = await myProvider.waitForTransaction(res2.transaction_hash); +``` + ### Sponsored Paymaster For a sponsored transaction, use: diff --git a/www/docs/guides/contracts/interact.md b/www/docs/guides/contracts/interact.md index efd167a2b..62931a5a7 100644 --- a/www/docs/guides/contracts/interact.md +++ b/www/docs/guides/contracts/interact.md @@ -46,7 +46,8 @@ console.log('User balance:', userBalance); - Cairo 1 contracts return values directly as `bigint` - Cairo 0 contracts return objects with named properties (e.g., `result.res`) - ::: + +::: ## ✍️ Writing to Contract State @@ -169,7 +170,7 @@ txR.match({ console.log('Reverted =', txR); }, error: (err: Error) => { - console.log('An error occured =', err); + console.log('An error occurred =', err); }, }); ```