Skip to content

feat: Implement SNIP29 #1377

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
143 changes: 143 additions & 0 deletions __tests__/accountPaymaster.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Account, Signature, Call, PaymasterDetails, PaymasterRpc } from '../src';

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

describe('Account - Paymaster integration', () => {
const mockBuildTransaction = jest.fn();
const mockExecuteTransaction = jest.fn();
const mockSignMessage = jest.fn();

const fakeTypedData = {
types: {},
domain: {},
primaryType: '',
message: {
caller: '0xcaller',
nonce: '0xnonce',
execute_after: '0x1',
execute_before: '0x2',
calls_len: '0x0',
calls: [],
},
};

const fakeSignature: Signature = ['0x1', '0x2'];
const calls: Call[] = [{ contractAddress: '0x123', entrypoint: 'transfer', calldata: [] }];

const paymasterResponse = {
type: 'invoke',
typed_data: fakeTypedData,
parameters: {
version: '0x1',
feeMode: { mode: 'default', gasToken: '0x456' },
},
fee: {
gas_token_price_in_strk: 200n,
estimated_fee_in_strk: 3000n,
estimated_fee_in_gas_token: 1000n,
suggested_max_fee_in_strk: 4000n,
suggested_max_fee_in_gas_token: 1200n,
},
};

const mockPaymaster = () =>
({
buildTransaction: mockBuildTransaction,
executeTransaction: mockExecuteTransaction,
}) as unknown as PaymasterRpc;

const setupAccount = () =>
new Account(
{},
'0xabc',
{ signMessage: mockSignMessage.mockResolvedValue(fakeSignature) } as any,
undefined,
undefined,
mockPaymaster()
);

beforeEach(() => {
jest.clearAllMocks();
(PaymasterRpc as jest.Mock).mockImplementation(mockPaymaster);
mockBuildTransaction.mockResolvedValue(paymasterResponse);
mockExecuteTransaction.mockResolvedValue({ transaction_hash: '0x123' });
});

describe('buildPaymasterTransaction', () => {
it('should return typed data and token prices from paymaster', async () => {
const account = setupAccount();

const result = await account.buildPaymasterTransaction(calls, {
feeMode: { mode: 'default', gasToken: '0x456' },
});

expect(mockBuildTransaction).toHaveBeenCalledWith(
{
type: 'invoke',
invoke: { userAddress: '0xabc', calls },
},
{
version: '0x1',
feeMode: { mode: 'default', gasToken: '0x456' },
timeBounds: undefined,
}
);

expect(result).toEqual(paymasterResponse);
});
});

describe('executePaymasterTransaction', () => {
it('should sign and execute transaction via paymaster', async () => {
const account = setupAccount();
const details: PaymasterDetails = {
feeMode: { mode: 'default', gasToken: '0x456' },
};

const result = await account.executePaymasterTransaction(calls, details);

expect(mockBuildTransaction).toHaveBeenCalledTimes(1);
expect(mockSignMessage).toHaveBeenCalledWith(fakeTypedData, '0xabc');
expect(mockExecuteTransaction).toHaveBeenCalledWith(
{
type: 'invoke',
invoke: {
userAddress: '0xabc',
typedData: fakeTypedData,
signature: ['0x1', '0x2'],
},
},
{
version: '0x1',
feeMode: { mode: 'default', gasToken: '0x456' },
timeBounds: undefined,
}
);
expect(result).toEqual({ transaction_hash: '0x123' });
});

it('should throw if estimated fee exceeds maxEstimatedFeeInGasToken', async () => {
const account = setupAccount();
const details: PaymasterDetails = {
feeMode: { mode: 'default', gasToken: '0x456' },
maxEstimatedFeeInGasToken: 500n,
};

await expect(account.executePaymasterTransaction(calls, details)).rejects.toThrow(
'Estimated max fee too high'
);
});

it('should throw if token price exceeds maxPriceInStrk', async () => {
const account = setupAccount();
const details: PaymasterDetails = {
feeMode: { mode: 'default', gasToken: '0x456' },
maxGasTokenPriceInStrk: 100n,
};

await expect(account.executePaymasterTransaction(calls, details)).rejects.toThrow(
'Gas token price is too high'
);
});
});
});
238 changes: 238 additions & 0 deletions __tests__/defaultPaymaster.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import {
RpcError,
PaymasterRpc,
ExecutionParameters,
UserTransaction,
ExecutableUserTransaction,
RPC,
} from '../src';
import fetchMock from '../src/utils/connect/fetch';
import { signatureToHexArray } from '../src/utils/stark';

jest.mock('../src/utils/connect/fetch');
jest.mock('../src/utils/stark', () => ({
signatureToHexArray: jest.fn(() => ['0x1', '0x2']),
}));
jest.mock('../src/utils/paymaster', () => ({
getDefaultPaymasterNodeUrl: jest.fn(() => 'https://mock-node-url'),
}));

describe('PaymasterRpc', () => {
const mockFetch = fetchMock as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
});

describe('constructor', () => {
it('should initialize with default values', () => {
// When
const client = new PaymasterRpc();

// Then
expect(client.nodeUrl).toBe('https://mock-node-url');
expect(client.requestId).toBe(0);
});
});

describe('isAvailable', () => {
it('should return true when paymaster is available', async () => {
// Given
const client = new PaymasterRpc();
mockFetch.mockResolvedValueOnce({
json: async () => ({ result: true }),
});

// When
const result = await client.isAvailable();

// Then
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining('"method":"paymaster_isAvailable"'),
})
);
});

it('should return false when paymaster is not available', async () => {
// Given
const client = new PaymasterRpc();
mockFetch.mockResolvedValueOnce({
json: async () => ({ result: false }),
});

// When
const result = await client.isAvailable();

// Then
expect(result).toBe(false);
});

it('should throw RpcError when RPC returns error', async () => {
// Given
const client = new PaymasterRpc();
mockFetch.mockResolvedValueOnce({
json: async () => ({ error: { code: -32000, message: 'RPC failure' } }),
});

// When / Then
await expect(client.isAvailable()).rejects.toThrow(RpcError);
});

it('should throw on network error', async () => {
// Given
const client = new PaymasterRpc();
mockFetch.mockRejectedValueOnce(new Error('Network down'));

// When / Then
await expect(client.isAvailable()).rejects.toThrow('Network down');
});
});

describe('buildTransaction', () => {
it('should return typedData and parsed tokenAmountAndPrice', async () => {
// Given
const client = new PaymasterRpc();
const mockCall = {
contractAddress: '0xabc',
entrypoint: 'transfer',
calldata: ['0x1', '0x2'],
};
const transaction: UserTransaction = {
type: 'invoke',
invoke: {
userAddress: '0xuser',
calls: [mockCall],
},
};
const parameters: ExecutionParameters = {
version: '0x1',
feeMode: {
mode: 'default',
gasToken: '0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7',
},
};

mockFetch.mockResolvedValueOnce({
json: async () => ({
result: {
type: 'invoke',
typed_data: { domain: {}, message: {}, types: {} },
parameters: {
version: '0x1',
fee_mode: {
mode: 'default',
gas_token: '0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7',
},
time_bounds: null,
},
fee: {
gas_token_price_in_strk: '0x5ffeeacbaf058dfee0',
estimated_fee_in_strk: '0xe8a2e6bd26e66',
estimated_fee_in_gas_token: '0x21a1a7339fd',
suggested_max_fee_in_strk: '0x2b9e8b43774b32',
suggested_max_fee_in_gas_token: '0x64e4f59adf7',
},
},
}),
});

// When
const result = await client.buildTransaction(transaction, parameters);

// Then
expect(result.fee.estimated_fee_in_strk).toBe(BigInt(0xe8a2e6bd26e66));
expect(result.fee.suggested_max_fee_in_strk).toBe(BigInt(0x2b9e8b43774b32));
expect(result.parameters.feeMode.mode).toBe('default');
expect(result.type).toBe('invoke');
// @ts-ignore
// eslint-disable-next-line
expect(result['typed_data']).toBeDefined();
});
});

describe('executeTransaction', () => {
it('should send execution request and return transaction hash', async () => {
// Given
const client = new PaymasterRpc();
const mockSignature = ['0x1', '0x2'];
const mockTypedData: RPC.PAYMASTER_API.OutsideExecutionTypedData = {
domain: {},
types: {},
primaryType: '',
message: {
caller: '0xcaller',
nonce: '0xnonce',
execute_after: '0x1',
execute_before: '0x2',
calls_len: '0x0',
calls: [],
},
};
const transaction: ExecutableUserTransaction = {
type: 'invoke',
invoke: {
userAddress: '0xuser',
typedData: mockTypedData,
signature: mockSignature,
},
};
const parameters: ExecutionParameters = {
version: '0x1',
feeMode: {
mode: 'default',
gasToken: '0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7',
},
};

mockFetch.mockResolvedValueOnce({
json: async () => ({
result: {
transaction_hash: '0xaaa',
execution_result: 'ok',
},
}),
});

// When
const result = await client.executeTransaction(transaction, parameters);

// Then
expect(signatureToHexArray).toHaveBeenCalledWith(mockSignature);
expect(result.transaction_hash).toBe('0xaaa');
});
});

describe('getSupportedTokens', () => {
it('should return supported tokens and prices', async () => {
// Given
const client = new PaymasterRpc();
const rpc_response = [
{
address: '0x53b40a647cedfca6ca84f542a0fe36736031905a9639a7f19a3c1e66bfd5080',
decimals: 6,
price_in_strk: '0x38aea',
},
];
const expected = [
{
address: '0x53b40a647cedfca6ca84f542a0fe36736031905a9639a7f19a3c1e66bfd5080',
decimals: 6,
priceInStrk: BigInt('0x38aea'),
},
];

mockFetch.mockResolvedValueOnce({
json: async () => ({ result: rpc_response }),
});

// When
const result = await client.getSupportedTokens();

// Then
expect(result).toEqual(expected);
});
});
});
Loading