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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [2.0.2] - 2025-07-24

### Added Changes

- Added `getEtherspotProvider` to access directly the Etherspot Provider.
- Added `getTransactionHash` to get a transaction hash with a userOp Hash and chain id.
- Update of the `modular-sdk` version to 6.1.1.

## [2.0.1] - 2025-07-17

### Documentation
Expand Down
90 changes: 85 additions & 5 deletions __tests__/EtherspotTransactionKit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('EtherspotTransactionKit', () => {
describe('Constructor', () => {
it('should initialize with config', () => {
expect(EtherspotProvider).toHaveBeenCalledWith(mockConfig);
expect(transactionKit.getProvider()).toBe(mockProvider);
expect(transactionKit.getEtherspotProvider()).toBe(mockProvider);
});

it('should set debug mode from config', () => {
Expand Down Expand Up @@ -796,7 +796,7 @@ describe('EtherspotTransactionKit', () => {

describe('getProvider', () => {
it('should return etherspot provider', () => {
const provider = transactionKit.getProvider();
const provider = transactionKit.getEtherspotProvider();

expect(provider).toBe(mockProvider);
});
Expand Down Expand Up @@ -963,7 +963,9 @@ describe('Batch operations', () => {
beforeEach(() => {
transactionKit = new EtherspotTransactionKit(mockConfig);
// Re-mock SDK for each test
transactionKit.getProvider().getSdk = jest.fn().mockResolvedValue(mockSdk);
transactionKit.getEtherspotProvider().getSdk = jest
.fn()
.mockResolvedValue(mockSdk);
mockSdk.clearUserOpsFromBatch.mockReset();
mockSdk.addUserOpsToBatch.mockReset();
mockSdk.estimate.mockReset();
Expand Down Expand Up @@ -1096,17 +1098,95 @@ describe('Batch operations', () => {

it('should throw if no provider in estimateBatches', async () => {
// @ts-ignore
transactionKit.getProvider().getProvider.mockReturnValue(null);
transactionKit.getProvider = jest.fn().mockReturnValue(null);
await expect(transactionKit.estimateBatches()).rejects.toThrow(
'estimateBatches(): No Web3 provider available. This is a critical configuration error.'
);
});

it('should throw if no provider in sendBatches', async () => {
// @ts-ignore
transactionKit.getProvider().getProvider.mockReturnValue(null);
transactionKit.getProvider = jest.fn().mockReturnValue(null);
await expect(transactionKit.sendBatches()).rejects.toThrow(
'sendBatches(): No Web3 provider available. This is a critical configuration error.'
);
});
});

describe('getTransactionHash', () => {
let transactionKit: EtherspotTransactionKit;
let mockSdk: jest.Mocked<ModularSdk>;

const userOpHash = '0xuserOpHash';
const txChainId = 1;

beforeEach(() => {
transactionKit = new EtherspotTransactionKit({
provider: {} as any,
chainId: txChainId,
bundlerApiKey: 'test-bundler-key',
debugMode: false,
});
mockSdk = {
getUserOpReceipt: jest.fn(),
} as any;
// Override getSdk to always return our mockSdk
transactionKit.getSdk = jest.fn().mockResolvedValue(mockSdk);
});

it('returns transaction hash when available immediately', async () => {
(mockSdk.getUserOpReceipt as jest.Mock).mockResolvedValue('0xhash');
const result = await transactionKit.getTransactionHash(
userOpHash,
txChainId,
1000,
10
);
expect(result).toBe('0xhash');
expect(mockSdk.getUserOpReceipt).toHaveBeenCalledWith(userOpHash);
});

it('returns transaction hash after several polls', async () => {
let callCount = 0;
(mockSdk.getUserOpReceipt as jest.Mock).mockImplementation(() => {
callCount++;
return callCount === 3 ? '0xhash' : null;
});
const result = await transactionKit.getTransactionHash(
userOpHash,
txChainId,
100,
1
);
expect(result).toBe('0xhash');
expect(callCount).toBeGreaterThan(1);
});

it('returns null on timeout', async () => {
(mockSdk.getUserOpReceipt as jest.Mock).mockResolvedValue(null);
const result = await transactionKit.getTransactionHash(
userOpHash,
txChainId,
50,
10
);
expect(result).toBeNull();
});

it('handles SDK errors gracefully and continues polling', async () => {
let callCount = 0;
(mockSdk.getUserOpReceipt as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount < 2) throw new Error('Temporary error');
return callCount === 3 ? '0xhash' : null;
});
const result = await transactionKit.getTransactionHash(
userOpHash,
txChainId,
100,
1
);
expect(result).toBe('0xhash');
expect(callCount).toBeGreaterThanOrEqual(3);
});
});
37 changes: 26 additions & 11 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { createWalletClient, custom } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { polygon } from 'viem/chains';

function bigIntReplacer(key: string, value: any) {
return typeof value === 'bigint' ? value.toString() : value;
}

const account = privateKeyToAccount(
`0x${process.env.REACT_APP_DEMO_WALLET_PK}` as `0x${string}`
);
Expand Down Expand Up @@ -133,7 +137,9 @@ const App = () => {
transactionName: 'tx1',
}) as INamedTransaction;
const result = await named.estimate();
logAndUpdateState('Estimate single tx1: ' + JSON.stringify(result));
logAndUpdateState(
'Estimate single tx1: ' + JSON.stringify(result, bigIntReplacer)
);
} catch (e) {
logAndUpdateState('Error: ' + (e as Error).message);
}
Expand All @@ -144,7 +150,9 @@ const App = () => {
action: async (logAndUpdateState: (msg: string) => void) => {
try {
const result = await kit.estimateBatches();
logAndUpdateState('Estimate batches: ' + JSON.stringify(result));
logAndUpdateState(
'Estimate batches: ' + JSON.stringify(result, bigIntReplacer)
);
} catch (e) {
logAndUpdateState('Error: ' + (e as Error).message);
}
Expand All @@ -161,12 +169,13 @@ const App = () => {
if (result.isSentSuccessfully) {
logAndUpdateState(
'Send single tx1: ' +
JSON.stringify(result) +
JSON.stringify(result, bigIntReplacer) +
' (tx1 removed from state)'
);
} else {
logAndUpdateState(
'Send single tx1 failed: ' + JSON.stringify(result)
'Send single tx1 failed: ' +
JSON.stringify(result, bigIntReplacer)
);
}
} catch (e) {
Expand All @@ -179,7 +188,7 @@ const App = () => {
action: async (logAndUpdateState: (msg: string) => void) => {
try {
const result = await kit.sendBatches();
let msg = 'Send batches: ' + JSON.stringify(result);
let msg = 'Send batches: ' + JSON.stringify(result, bigIntReplacer);
// Check which batches were removed
if (result && result.batches) {
const removedBatches = Object.entries(result.batches)
Expand Down Expand Up @@ -476,7 +485,9 @@ const App = () => {
const result = await kit.estimateBatches({
onlyBatchNames: ['batchD'],
});
logAndUpdateState('Estimate empty batchD: ' + JSON.stringify(result));
logAndUpdateState(
'Estimate empty batchD: ' + JSON.stringify(result, bigIntReplacer)
);
} catch (e) {
logAndUpdateState('Error: ' + (e as Error).message);
}
Expand All @@ -487,7 +498,9 @@ const App = () => {
action: async (logAndUpdateState: (msg: string) => void) => {
try {
const result = await kit.sendBatches({ onlyBatchNames: ['batchD'] });
logAndUpdateState('Send empty batchD: ' + JSON.stringify(result));
logAndUpdateState(
'Send empty batchD: ' + JSON.stringify(result, bigIntReplacer)
);
} catch (e) {
logAndUpdateState('Error: ' + (e as Error).message);
}
Expand Down Expand Up @@ -662,7 +675,8 @@ const App = () => {
onlyBatchNames: ['doesnotexistbatch'],
});
logAndUpdateState(
'EstimateBatches with nonexistent batch: ' + JSON.stringify(result)
'EstimateBatches with nonexistent batch: ' +
JSON.stringify(result, bigIntReplacer)
);
} catch (e) {
logAndUpdateState('Error: ' + (e as Error).message);
Expand All @@ -677,7 +691,8 @@ const App = () => {
onlyBatchNames: ['doesnotexistbatch'],
});
let msg =
'SendBatches with nonexistent batch: ' + JSON.stringify(result);
'SendBatches with nonexistent batch: ' +
JSON.stringify(result, bigIntReplacer);
if (
result &&
result.batches &&
Expand All @@ -698,7 +713,7 @@ const App = () => {
const logAndUpdateState = (msg: string) => {
setLogs((prev) => [
msg,
'State: ' + JSON.stringify(kit.getState(), null, 2),
'State: ' + JSON.stringify(kit.getState(), bigIntReplacer, 2),
...prev,
]);
setCurrentState(kit.getState());
Expand Down Expand Up @@ -742,7 +757,7 @@ const App = () => {
<div style={{ marginTop: 24 }}>
<h3>Current State</h3>
<pre style={{ background: '#111', color: '#0f0', padding: 12 }}>
{JSON.stringify(currentState, null, 2)}
{JSON.stringify(currentState, bigIntReplacer, 2)}
</pre>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion lib/EtherspotUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class EtherspotUtils {
'0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF',
];
return zeroAddresses.some((zeroAddress) =>
this.addressesEqual(zeroAddress, address)
EtherspotUtils.addressesEqual(zeroAddress, address)
);
}

Expand Down
75 changes: 60 additions & 15 deletions lib/TransactionKit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ModularSdk } from '@etherspot/modular-sdk';
import { ModularSdk, WalletProviderLike } from '@etherspot/modular-sdk';
import { isAddress } from 'viem';

// interfaces
Expand Down Expand Up @@ -589,7 +589,7 @@ export class EtherspotTransactionKit implements IInitial {
}

log('estimate(): Getting provider...', undefined, this.debugMode);
const provider = this.etherspotProvider.getProvider();
const provider = this.getProvider();
log('estimate(): Got provider:', provider, this.debugMode);
if (!provider) {
log(
Expand Down Expand Up @@ -817,7 +817,7 @@ export class EtherspotTransactionKit implements IInitial {
}

log('send(): Getting provider...', undefined, this.debugMode);
const provider = this.etherspotProvider.getProvider();
const provider = this.getProvider();
log('send(): Got provider:', provider, this.debugMode);
if (!provider) {
log(
Expand Down Expand Up @@ -1086,7 +1086,7 @@ export class EtherspotTransactionKit implements IInitial {
}

// Get the provider
const provider = this.etherspotProvider.getProvider();
const provider = this.getProvider();
// Validation: if there is no provider, return error
if (!provider) {
log(
Expand Down Expand Up @@ -1357,7 +1357,7 @@ export class EtherspotTransactionKit implements IInitial {
}

// Get the provider
const provider = this.etherspotProvider.getProvider();
const provider = this.getProvider();
// Validation: if there is no provider, return error
if (!provider) {
log(
Expand Down Expand Up @@ -1753,20 +1753,24 @@ export class EtherspotTransactionKit implements IInitial {
}

/**
* Returns the underlying EtherspotProvider instance used by this kit.
* Returns the underlying raw provider used by this kit (WalletProviderLike).
*
* @returns The EtherspotProvider instance.
* @returns The WalletProviderLike instance.
*
* @remarks
* - Useful for advanced operations or direct provider access.
* - Does not mutate any internal state.
* - This is the provider you should use in your app for web3 interactions.
* - For advanced operations, use getEtherspotProvider().
*/
getProvider(): EtherspotProvider {
log(
'getProvider(): Returning provider',
this.etherspotProvider,
this.debugMode
);
getProvider(): WalletProviderLike {
return this.etherspotProvider.getProvider();
}

/**
* Returns the EtherspotProvider instance for advanced use.
*
* @returns The EtherspotProvider instance.
*/
getEtherspotProvider(): EtherspotProvider {
return this.etherspotProvider;
}

Expand All @@ -1791,6 +1795,47 @@ export class EtherspotTransactionKit implements IInitial {
return sdk;
}

/**
* Polls for the transaction hash using a user operation hash and chain ID.
*
* @param userOpHash - The user operation hash to query.
* @param txChainId - The chain ID to use for the SDK.
* @param timeout - (Optional) Timeout in ms (default: 60000).
* @param retryInterval - (Optional) Polling interval in ms (default: 2000).
* @returns The transaction hash as a string, or null if not found in time.
*/
public async getTransactionHash(
userOpHash: string,
txChainId: number,
timeout: number = 60 * 1000,
retryInterval: number = 2000
): Promise<string | null> {
const etherspotModulaSdk = await this.getSdk(txChainId);

let transactionHash: string | null = null;
const timeoutTotal = Date.now() + timeout;

while (!transactionHash && Date.now() < timeoutTotal) {
await new Promise<void>((resolve) => setTimeout(resolve, retryInterval));
try {
transactionHash = await etherspotModulaSdk.getUserOpReceipt(userOpHash);
} catch (error) {
console.error(
'Error fetching transaction hash. Please check if the transaction has gone through, or try to send the transaction again:',
error
);
}
}

if (!transactionHash) {
console.warn(
'Failed to get the transaction hash within time limit. Please try again'
);
}

return transactionHash;
}

/**
* Resets all internal state, clearing all transactions, batches, and caches.
*
Expand Down
Loading