Skip to content
Open
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
166 changes: 166 additions & 0 deletions PAYMASTER_PLUGIN_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Paymaster Plugin Migration — Breaking Changes

## Summary

The SNIP-29 paymaster functionality has been extracted from core modules into `src/plugins/paymaster/`. All paymaster-specific code now lives exclusively in the plugin directory. The paymaster plugin is installed by default, so `account.executePaymasterTransaction()` and other methods work out of the box.

---

## Top-level Named Exports

| Old export | New export | Breaking? |
| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------- |
| `PaymasterRpc` | `PaymasterRpc` | No |
| `PaymasterInterface` | `PaymasterInterface` | No |
| `type PaymasterDetails` | Removed from top-level | **Yes** — import from `'starknet/plugins/paymaster'` |
| `type PaymasterFeeEstimate` | Removed from top-level | **Yes** — import from `'starknet/plugins/paymaster'` |
| `type PaymasterOptions` | Removed from top-level | **Yes** — import from `'starknet/plugins/paymaster'` |
| `type PaymasterRpcOptions` | Removed from top-level | **Yes** — import from `'starknet/plugins/paymaster'` |
| `type PaymasterTimeBounds` | Removed from top-level | **Yes** — import from `'starknet/plugins/paymaster'` |
| `paymaster` (utils namespace) | Removed | **Yes** — use `paymasterUtils` or import utils directly from plugin |
| — | `paymasterPlugin` (factory fn) | New |
| — | `paymasterUtils` (utils namespace) | New |
| — | `type PaymasterAccountMethods` | New |
| — | `type PaymasterContractMethods` | New |

### Migration examples

```typescript
// OLD:
import { PaymasterDetails, PaymasterFeeEstimate, paymaster } from 'starknet';
paymaster.getDefaultPaymasterNodeUrl();

// NEW:
import { paymasterUtils } from 'starknet';
import type { PaymasterDetails, PaymasterFeeEstimate } from 'starknet/plugins/paymaster';
paymasterUtils.getDefaultPaymasterNodeUrl();
```

---

## Account API

| Old API | New API | Breaking? |
| --------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ |
| `AccountOptions.paymaster` | `AccountOptions.plugins: { paymaster: ... }` | **Yes** |
| `account.paymaster` (class property) | `account.paymaster` (plugin-injected property) | No — same access pattern |
| `account.buildPaymasterTransaction()` | Same (now from plugin) | No |
| `account.estimatePaymasterTransactionFee()` | Same (now from plugin) | No |
| `account.executePaymasterTransaction()` | Same (now from plugin) | No |
| `account.preparePaymasterTransaction()` | Same (now from plugin) | No |
| — | `account.isPaymasterAvailable()` | New |
| — | `account.getPaymasterSupportedTokens()` | New |
| `AccountInterface` had 3 abstract paymaster methods | Removed from abstract class | **Yes** — custom `AccountInterface` implementations no longer need these |

### Migration examples

```typescript
// OLD:
const account = new Account({
provider,
address,
signer,
paymaster: { nodeUrl: 'https://custom.paymaster.url' },
});

// NEW (default — auto-configured, no config needed):
const account = new Account({ provider, address, signer });

// NEW (custom paymaster URL):
const account = new Account({
provider,
address,
signer,
plugins: { paymaster: { nodeUrl: 'https://custom.paymaster.url' } },
});

// NEW (disable paymaster):
const account = new Account({
provider,
address,
signer,
plugins: { paymaster: false },
});
```

---

## Contract API

| Old API | New API | Breaking? |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------- | ------------------------ |
| `contract.invoke(m, args, { paymasterDetails })` | `contract.invokePaymaster(m, args, paymasterDetails)` | **Yes** — renamed method |
| `contract.estimate(m, args, { paymasterDetails })` | `contract.estimatePaymaster(m, args, paymasterDetails)` | **Yes** — renamed method |
| `ExecuteOptions.paymasterDetails` | Removed | **Yes** |
| `ExecuteOptions.maxFeeInGasToken` | Removed | **Yes** |
| `contract.estimate()` returned `EstimateFeeResponseOverhead \| PaymasterFeeEstimate` | Returns only `EstimateFeeResponseOverhead` | **Yes** |
| — | `contract.invokePaymaster()` (plugin-injected) | New |
| — | `contract.estimatePaymaster()` (plugin-injected) | New |
| — | `type PaymasterContractMethods` | New |

Contract paymaster methods are injected automatically by the paymaster plugin's `contractExtend` hook when a Contract is connected to an Account that has the paymaster plugin installed. No paymaster knowledge exists in the Contract core — it comes entirely from the plugin.

### Migration examples

```typescript
// OLD:
const fee = await contract.estimate('transfer', [to, amount], { paymasterDetails });
const res = await contract.invoke('transfer', [to, amount], { paymasterDetails });

// NEW — contract-level convenience methods (plugin-injected):
const fee = await contract.estimatePaymaster('transfer', [to, amount], paymasterDetails);
const res = await contract.invokePaymaster('transfer', [to, amount], paymasterDetails);

// NEW — alternatively, use account methods directly:
const call = contract.populate('transfer', [to, amount]);
const fee = await account.estimatePaymasterTransactionFee([call], paymasterDetails);
const res = await account.executePaymasterTransaction([call], paymasterDetails);
```

---

## Wallet API

| Old API | New API | Breaking? |
| --------------------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------- |
| `WalletAccount.connect(provider, wallet, cairo, paymaster, silent)` | `WalletAccount.connect(provider, wallet, cairo, silent)` | **Yes** — `paymaster` param removed |
| `WalletAccountV5.connect(provider, wallet, cairo, paymaster, silent)` | `WalletAccountV5.connect(provider, wallet, cairo, silent)` | **Yes** — `paymaster` param removed |
| `WalletAccountV4Options.paymaster` | Removed | **Yes** |
| `WalletAccountV5Options.paymaster` | Removed | **Yes** |

---

## Constants

| Old | New | Breaking? |
| ------------------------------- | ------------------------------- | ---------------------------------------------- |
| `constants.PAYMASTER_RPC_NODES` | Removed from `global/constants` | **Yes** — moved to plugin internal, not public |

---

## Plugin System — `contractExtend` Hook

The `StarknetPlugin` interface now supports a third type parameter `TContractMethods` and a `contractExtend` method:

```typescript
interface StarknetPlugin<TProviderMethods, TAccountMethods, TContractMethods> {
// ...existing methods...
contractExtend?(contract: ContractInterface, account: AccountInterface): TContractMethods;
}
```

- `contractExtend` is called automatically in the Contract constructor when connected to an Account with plugins
- It receives both the Contract and the Account so the plugin can delegate to account-level methods
- No `PluginManager` is created on Contract — it reuses the Account's registered plugins
- `PluginManager.installOnContract()` handles calling `contractExtend` and assigning methods

---

## Non-breaking Changes

- `PaymasterRpc` and `PaymasterInterface` remain importable from `'starknet'`
- `account.paymaster` property still works (now injected by plugin via `Object.assign`)
- All paymaster account methods (`executePaymasterTransaction`, etc.) work identically — they are just provided by the plugin augmentation instead of the abstract class
- Paymaster plugin is a **default plugin** — installed automatically unless explicitly disabled
- `contract.invokePaymaster()` and `contract.estimatePaymaster()` are available on any Contract connected to an Account with the paymaster plugin
- `src/utils/src5.ts` `supportsInterface` now accepts `ProviderInterface` instead of `RpcProvider` (wider type, non-breaking)
29 changes: 13 additions & 16 deletions __tests__/accountPaymaster.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import type { OutsideCallV2, OutsideExecutionTypedDataV2 } from '../src/types/api';
import {
Account,
OutsideExecutionVersion,
logger,
hash,
type Call,
type PaymasterDetails,
type Signature,
} from '../src';
import { Account, logger, hash, type Call, type Signature } from '../src';
import type { PaymasterDetails } from '../src/plugins/paymaster';

jest.mock('../src/paymaster/rpc');
import { supportsInterface } from '../src/utils/src5';

jest.mock('../src/utils/src5', () => ({
supportsInterface: jest.fn().mockResolvedValue(true),
}));
logger.setLogLevel('ERROR');

describe('Account - Paymaster integration', () => {
Expand All @@ -19,7 +16,6 @@ describe('Account - Paymaster integration', () => {
const mockMaliciousBuildTransactionChangeFees = jest.fn();
const mockMaliciousBuildTransactionAddedCalls = jest.fn();
const mockExecuteTransaction = jest.fn();
const mockGetSnip9Version = jest.fn();
const mockSignMessage = jest.fn();

const fakeSignature: Signature = ['0x1', '0x2'];
Expand Down Expand Up @@ -141,24 +137,25 @@ describe('Account - Paymaster integration', () => {
address: '0xabc',
signer: { signMessage: mockSignMessage.mockResolvedValue(fakeSignature) } as any,
});
// account object is instanciate in the constructor, we need to mock the paymaster methods after paymaster object is instanciate
account.paymaster.buildTransaction = mockBuildTransaction;
account.paymaster.executeTransaction = mockExecuteTransaction;
// The paymaster is a plugin; access it via account.paymaster property
const pm = account.paymaster;
pm.buildTransaction = mockBuildTransaction;
pm.executeTransaction = mockExecuteTransaction;
}
return account;
};

beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(getAccount(), 'getSnip9Version').mockImplementation(mockGetSnip9Version);
// Re-mock supportsInterface after clearAllMocks
(supportsInterface as jest.Mock).mockResolvedValue(true);
mockBuildTransaction.mockResolvedValue(paymasterResponse);
mockMaliciousBuildTransactionChangeToken.mockResolvedValue(
maliciousPaymasterResponseChangeToken
);
mockMaliciousBuildTransactionChangeFees.mockResolvedValue(maliciousPaymasterResponseChangeFees);
mockMaliciousBuildTransactionAddedCalls.mockResolvedValue(maliciousPaymasterResponseAddedCalls);
mockExecuteTransaction.mockResolvedValue({ transaction_hash: '0x123' });
mockGetSnip9Version.mockResolvedValue(OutsideExecutionVersion.V2);
});

describe('estimatePaymasterTransactionFee', () => {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/config/helpers/testInstances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,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,
...(paymasterSnip29 ? { plugins: { paymaster: paymasterSnip29 } } : {}),
})
);
};
Expand Down
83 changes: 35 additions & 48 deletions __tests__/contractPaymaster.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import {
type RpcProvider,
type Account,
Contract,
PaymasterRpc,
OutsideExecutionVersion,
type TokenData,
num,
type PaymasterDetails,
cairo,
type PaymasterFeeEstimate,
} from '../src';
import type { PaymasterDetails, PaymasterFeeEstimate, TokenData } from '../src/plugins/paymaster';
import { describeIfTestnet, getTestAccount, getTestProvider, STRKtokenAddress } from './config';

describeIfTestnet('Paymaster with Contract, in Testnet', () => {
describeIfTestnet('Paymaster with Account, in Testnet', () => {
let provider: RpcProvider;
let myAccount: Account;
let strkContract: Contract;
const feesDetails: PaymasterDetails = {
feeMode: { mode: 'default', gasToken: STRKtokenAddress },
};
Expand All @@ -24,68 +20,59 @@ describeIfTestnet('Paymaster with Contract, in Testnet', () => {
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();
const isPaymasterAvailable = await myAccount.isPaymasterAvailable();
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 supported: TokenData[] = await myAccount.getPaymasterSupportedTokens();
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',
test('Estimate fee with Paymaster', async () => {
const estimation: PaymasterFeeEstimate = await myAccount.estimatePaymasterTransactionFee(
[
'0x010101', // random address
cairo.uint256(10), // dust of STRK
{
contractAddress: STRKtokenAddress,
entrypoint: 'transfer',
calldata: ['0x010101', cairo.uint256(10)],
},
],
{
paymasterDetails: feesDetails,
}
)) as PaymasterFeeEstimate;
feesDetails
);
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,
});
test('Execute with Paymaster', async () => {
const res1 = await myAccount.executePaymasterTransaction(
[
{
contractAddress: STRKtokenAddress,
entrypoint: 'transfer',
calldata: ['0x010101', cairo.uint256(100)],
},
],
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: 1n * 10n ** 18n,
});
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: 1n * 10n ** 18n,
})
.transfer('0x010101', cairo.uint256(103));
const res2 = await myAccount.executePaymasterTransaction(
[
{
contractAddress: STRKtokenAddress,
entrypoint: 'transfer',
calldata: ['0x010101', cairo.uint256(101)],
},
],
feesDetails,
1n * 10n ** 18n
);
const txR2 = await provider.waitForTransaction(res2.transaction_hash);
expect(txR2.isSuccess()).toBe(true);
});
Expand Down
18 changes: 9 additions & 9 deletions __tests__/defaultPaymaster.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import {
PaymasterRpc,
RpcError,
RPC,
type ExecutableUserTransaction,
type ExecutionParameters,
type UserTransaction,
} from '../src';
import { PaymasterRpc, RpcError, RPC } from '../src';
import type {
ExecutableUserTransaction,
ExecutionParameters,
UserTransaction,
} from '../src/plugins/paymaster';

import fetchMock from '../src/utils/connect/fetch';
import { signatureToHexArray } from '../src/utils/stark';
Expand All @@ -14,8 +12,10 @@ jest.mock('../src/utils/connect/fetch');
jest.mock('../src/utils/stark', () => ({
signatureToHexArray: jest.fn(() => ['0x1', '0x2']),
}));
jest.mock('../src/utils/paymaster', () => ({
jest.mock('../src/plugins/paymaster/utils', () => ({
getDefaultPaymasterNodeUrl: jest.fn(() => 'https://mock-node-url'),
assertCallsAreStrictlyEqual: jest.fn(),
assertPaymasterTransactionSafety: jest.fn(),
}));

describe('PaymasterRpc', () => {
Expand Down
Loading
Loading