Skip to content

Commit 07d0c7c

Browse files
authored
feat: ws (#31)
* feat: ws * refactor: error handling * refactor: helper * chore: update readme * fix: timeout * chore: update readme
1 parent 8ad4cc7 commit 07d0c7c

49 files changed

Lines changed: 3190 additions & 421 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/beige-plants-strive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@gelatocloud/gasless": patch
3+
---
4+
5+
feat: ws

.changeset/config.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
"bundler-gelato-sponsored-payment",
1010
"bundler-kernel-sponsored-payment",
1111
"relayer-sponsored-payment",
12-
"account-sponsored-payment"
12+
"account-sponsored-payment",
13+
"account-ws",
14+
"relayer-ws",
15+
"bundler-ws"
1316
],
1417
"linked": [],
1518
"updateInternalDependencies": "patch"

README.md

Lines changed: 171 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- **2D nonce support** - Advanced nonce management using both `nonce` and `nonceKey` for parallelized execution.
1515
- **Type-safe** - Implemented on top of [viem](https://viem.sh), offering complete TypeScript type safety and developer ergonomics.
1616
- **Synchronous methods**: Send transaction and get the receipt in a single call
17+
- **WebSockets**: Live notifications and updates via WebSockets
1718

1819
### Learn more in our [docs](https://docs.gelato.cloud)
1920

@@ -62,27 +63,25 @@ console.log(`Transaction hash: ${receipt.transactionHash}`);
6263

6364
**Asynchronous:**
6465
```typescript
65-
import { createGelatoEvmRelayerClient, StatusCode } from '@gelatocloud/gasless';
66+
import { createGelatoEvmRelayerClient } from '@gelatocloud/gasless';
6667
import { baseSepolia } from 'viem/chains';
6768

6869
const relayer = createGelatoEvmRelayerClient({
6970
apiKey: process.env.GELATO_API_KEY,
7071
testnet: true
7172
});
7273

73-
// Send transaction (returns immediately with task ID)
74-
const taskId = await relayer.sendTransaction({
74+
// Send transaction (returns immediately with ID)
75+
const id = await relayer.sendTransaction({
7576
chainId: baseSepolia.id,
7677
to: '0xTargetContract...',
7778
data: '0xCalldata...'
7879
});
7980

8081
// Poll for status separately
81-
const { status, receipt } = await relayer.waitForStatus({ id: taskId });
82+
const receipt = await relayer.waitForReceipt({ id });
8283

83-
if (status.status === StatusCode.Success) {
84-
console.log(`Transaction hash: ${receipt.transactionHash}`);
85-
}
84+
console.log(`Transaction hash: ${receipt.transactionHash}`);
8685
```
8786

8887
### Account (Gelato Smart Account)
@@ -129,21 +128,18 @@ console.log(`Transaction hash: ${receipt.transactionHash}`);
129128
```typescript
130129
import {
131130
createGelatoSmartAccountClient,
132-
toGelatoSmartAccount,
133-
StatusCode } from '@gelatocloud/gasless';
131+
toGelatoSmartAccount } from '@gelatocloud/gasless';
134132

135133
// ... same setup as above ...
136134

137-
// Send transaction (returns immediately with task ID)
138-
const taskId = await client.sendTransaction({
135+
// Send transaction (returns immediately with ID)
136+
const id = await client.sendTransaction({
139137
calls: [{ to: '0xContract...', data: '0xCalldata...' }] });
140138

141139
// Poll for status separately
142-
const status = await client.waitForStatus({ id: taskId });
140+
const receipt = await client.waitForReceipt({ id });
143141

144-
if (status.status === StatusCode.Success) {
145-
console.log(`Transaction hash: ${status.receipt.transactionHash}`);
146-
}
142+
console.log(`Transaction hash: ${receipt.transactionHash}`);
147143
```
148144

149145

@@ -190,6 +186,102 @@ const { receipt } = await bundler.waitForUserOperationReceipt({ hash });
190186
console.log(`Transaction hash: ${receipt.transactionHash}`);
191187
```
192188

189+
### WebSocket Subscriptions
190+
191+
WebSockets are enabled by default. Methods automatically race WebSocket notifications against HTTP polling for the fastest result. To disable:
192+
193+
```typescript
194+
const relayer = createGelatoEvmRelayerClient({
195+
apiKey: process.env.GELATO_API_KEY,
196+
ws: { disable: true }
197+
});
198+
```
199+
200+
**Relayer — single transaction:**
201+
```typescript
202+
const id = await relayer.sendTransaction({
203+
chainId: baseSepolia.id,
204+
to: '0xTargetContract...',
205+
data: '0xCalldata...'
206+
});
207+
208+
const subscription = await relayer.ws.subscribe({ id });
209+
210+
subscription.on('success', (data) => {
211+
console.log(`Included in block ${data.receipt.blockNumber}`);
212+
});
213+
214+
subscription.on('reverted', (data) => {
215+
console.log(`Reverted: ${data.receipt.blockNumber}`);
216+
});
217+
218+
// Cleanup when done
219+
await relayer.ws.unsubscribe(subscription.subscriptionId);
220+
relayer.ws.disconnect();
221+
```
222+
223+
**Relayer — global (all transactions):**
224+
```typescript
225+
const subscription = await relayer.ws.subscribe();
226+
227+
subscription.on('submitted', (data) => console.log(`${data.id} submitted`));
228+
subscription.on('success', (data) => console.log(`${data.id} success`));
229+
subscription.on('rejected', (data) => console.log(`${data.id} rejected`));
230+
```
231+
232+
**Bundler** — same patterns apply via `bundler.ws`:
233+
```typescript
234+
const subscription = await bundler.ws.subscribe({ id: userOpHash });
235+
subscription.on('success', (data) => console.log(data.receipt));
236+
```
237+
238+
**Events:**
239+
240+
| Event | Description |
241+
|-------------|-------------------------------------|
242+
| `pending` | Transaction pending |
243+
| `submitted` | Submitted to network |
244+
| `success` | Successfully included on-chain |
245+
| `rejected` | Rejected by relayer |
246+
| `reverted` | Reverted on-chain |
247+
248+
**Cleanup:**
249+
```typescript
250+
await relayer.ws.unsubscribe(subscription.subscriptionId);
251+
relayer.ws.disconnect();
252+
```
253+
254+
## Sync vs Async
255+
256+
The SDK offers two ways to send transactions:
257+
258+
| | Sync (`sendTransactionSync`) | Async (`sendTransaction` + WS) |
259+
|---|---|---|
260+
| Returns | Final `TransactionReceipt` | ID (`Hex`) immediately |
261+
| Lifecycle events | None (handled internally) | All: `pending`, `submitted`, `success`, `rejected`, `reverted` |
262+
| Tx hash on (re)submission | Not exposed | `hash` field on every `submitted` event |
263+
| Control | Minimal — fire and forget | Full — react to each status change via WS subscription |
264+
265+
**Sync** — call `sendTransactionSync` to send the transaction and get the receipt in the same call. If the transaction is not included, the SDK will handle it internally and return a final `TransactionReceipt` by racing http polls and ws updates. Simplest approach when you just need the result:
266+
267+
```typescript
268+
const receipt = await relayer.sendTransactionSync({ chainId, to, data });
269+
```
270+
271+
**Async** — call `sendTransaction` to get a ID immediately, then subscribe via WebSocket for granular status updates. **WS subscriptions give you full control over the transaction lifecycle** — you can react to resubmissions, display tx hashes to users in real time, handle rejections immediately, and more. The `submitted` event includes the transaction `hash` each time the relay (re)submits the transaction (e.g. with bumped gas):
272+
273+
```typescript
274+
const id = await relayer.sendTransaction({ chainId, to, data });
275+
const subscription = await relayer.ws.subscribe({ id });
276+
277+
subscription.on('submitted', (data) => {
278+
console.log(`Tx hash: ${data.hash}`); // available on every (re)submission
279+
});
280+
281+
subscription.on('success', (data) => {
282+
console.log(`Confirmed in block ${data.receipt.blockNumber}`);
283+
});
284+
```
193285

194286
## API Reference
195287

@@ -211,10 +303,9 @@ const client = createGelatoEvmRelayerClient({
211303
| Method | Parameters | Returns | Description |
212304
|--------|------------|---------|-------------|
213305
| `sendTransaction` | `{ chainId, to, data, authorizationList?, context? }` | `Promise<Hex>` | Submit a transaction |
214-
| `sendTransactionSync` | `{ chainId, to, data, timeout?, pollingInterval?, ... }` | `Promise<TransactionReceipt>` | Send and wait for receipt |
306+
| `sendTransactionSync` | `{ chainId, to, data, timeout?, pollingInterval?, throwOnReverted?, ... }` | `Promise<TransactionReceipt>` | Send and wait for receipt |
215307
| `getStatus` | `{ id: string }` | `Promise<Status>` | Get transaction status |
216-
| `waitForStatus` | `{ id: string, timeout?, pollingInterval? }` | `Promise<TerminalStatus>` | Wait for final status |
217-
| `waitForReceipt` | `{ id: string, timeout?, pollingInterval? }` | `Promise<TransactionReceipt>` | Wait for receipt, throws on failure |
308+
| `waitForReceipt` | `{ id: string, timeout?, pollingInterval?, throwOnReverted? }` | `Promise<TransactionReceipt>` | Wait for receipt, throws on failure |
218309
| `getCapabilities` | - | `Promise<Capabilities>` | Get supported chains |
219310
| `getFeeData` | `{ chainId, gas, l1Fee? }` | `Promise<FeeData>` | Get network fee data |
220311

@@ -249,10 +340,9 @@ const client = await createGelatoSmartAccountClient({
249340
| Method | Parameters | Returns | Description |
250341
|--------|------------|---------|-------------|
251342
| `sendTransaction` | `{ calls, nonce?, nonceKey?}` | `Promise<Hex>` | Send transaction(s) |
252-
| `sendTransactionSync` | `{ calls, nonce?, nonceKey?, timeout?, pollingInterval?, ... }` | `Promise<TransactionReceipt>` | Send and wait for receipt |
343+
| `sendTransactionSync` | `{ calls, nonce?, nonceKey?, timeout?, pollingInterval?, throwOnReverted?, ... }` | `Promise<TransactionReceipt>` | Send and wait for receipt |
253344
| `getStatus` | `{ id: string }` | `Promise<Status>` | Get transaction status |
254-
| `waitForStatus` | `{ id: string, timeout?, pollingInterval? }` | `Promise<TerminalStatus>` | Wait for final status |
255-
| `waitForReceipt` | `{ id: string, timeout?, pollingInterval? }` | `Promise<TransactionReceipt>` | Wait for receipt, throws on failure |
345+
| `waitForReceipt` | `{ id: string, timeout?, pollingInterval?, throwOnReverted? }` | `Promise<TransactionReceipt>` | Wait for receipt, throws on failure |
256346
| `getCapabilities` | - | `Promise<Capabilities>` | Get supported chains |
257347

258348
**Nonce Options:**
@@ -261,7 +351,7 @@ const client = await createGelatoSmartAccountClient({
261351

262352
**Polling Configuration:**
263353

264-
All synchronous methods (`sendTransactionSync`, `sendUserOperationSync`, `waitForStatus`, `waitForReceipt`) support customizable polling behavior:
354+
All synchronous methods (`sendTransactionSync`, `sendUserOperationSync`, `waitForReceipt`) support customizable polling behavior:
265355

266356
- `timeout` (optional): Maximum wait time in milliseconds
267357
- Default: `120000` (2 minutes)
@@ -370,9 +460,7 @@ type Call = {
370460
## Status Handling
371461

372462
```typescript
373-
import { StatusCode } from '@gelatocloud/gasless';
374-
375-
const status = await client.waitForStatus({ id: hash });
463+
const status = await client.getStatus({ id: hash });
376464

377465
switch (status.status) {
378466
case StatusCode.Success:
@@ -391,7 +479,7 @@ switch (status.status) {
391479

392480
### Timeout Errors
393481

394-
Synchronous methods (`sendTransactionSync`, `waitForStatus`, `waitForReceipt`) throw `TimeoutError` when operations don't complete within the configured timeout:
482+
Synchronous methods (`sendTransactionSync`, `waitForReceipt`) throw `TimeoutError` when operations don't complete within the configured timeout:
395483

396484
```typescript
397485
import { TimeoutError } from '@gelatocloud/gasless';
@@ -414,57 +502,84 @@ try {
414502
}
415503
```
416504

417-
### Automatic Fallback on Timeout
505+
### Revert Handling
418506

419-
When `sendTransactionSync` times out, it automatically falls back to polling for the transaction status. If you see a warning message like:
507+
By default, `sendTransactionSync` and `waitForReceipt` return the receipt even when a transaction reverts on-chain. Set `throwOnReverted: true` to throw a `TransactionRevertedError` instead:
420508

421-
```
422-
Transaction 0x... sync call timed out, falling back to polling for completion. DO NOT RETRY this transaction.
509+
```typescript
510+
import { TransactionRevertedError } from '@gelatocloud/gasless';
511+
512+
try {
513+
const receipt = await relayer.sendTransactionSync({
514+
chainId: baseSepolia.id,
515+
to: '0xTargetContract...',
516+
data: '0xCalldata...',
517+
throwOnReverted: true
518+
});
519+
} catch (error) {
520+
if (error instanceof TransactionRevertedError) {
521+
console.error('Transaction reverted:', error.receipt.transactionHash);
522+
console.error('Error message:', error.erroMessage);
523+
console.error('Error data:', error.errorData);
524+
// Also available: error.id, error.chainId, error.createdAt
525+
}
526+
}
423527
```
424528

425-
This means your transaction was successfully submitted but the sync method timed out. The SDK will continue polling for completion automatically. **Do not retry the operation** as this could result in duplicate transactions.
529+
Available on both relayer and account clients via `sendTransactionSync` and `waitForReceipt`. Properties on `TransactionRevertedError`: `receipt`, `id`, `chainId`, `createdAt`, `errorData`, `errorMessage`.
426530

427-
### Recovery Strategies
531+
### Simulation Errors
428532

429-
If a timeout occurs:
430-
1. **Wait for automatic fallback**: `sendTransactionSync` automatically polls after timeout
431-
2. **Check status manually**: Use `getStatus({ id })` to check if transaction is still processing
432-
3. **Retry with longer timeout**: Increase `timeout` and call `waitForStatus` again
433-
4. **Use async methods**: Switch to async pattern for more control
533+
When the relayer simulates a transaction before submission and the simulation reverts, a `SimulationFailedRpcError` is thrown with error code `4211`. This happens _before_ the transaction is sent on-chain:
434534

435535
```typescript
536+
import { SimulationFailedRpcError } from '@gelatocloud/gasless';
537+
436538
try {
437-
// Try with default 10s timeout
438539
const receipt = await relayer.sendTransactionSync({
439540
chainId: baseSepolia.id,
440541
to: '0xTargetContract...',
441-
data: '0xCalldata...',
542+
data: '0xCalldata...'
442543
});
443544
} catch (error) {
444-
if (error instanceof TimeoutError) {
445-
// Retry with 60s timeout
446-
const taskId = await relayer.sendTransaction({
447-
chainId: baseSepolia.id,
448-
to: '0xTargetContract...',
449-
data: '0xCalldata...',
450-
});
451-
452-
const status = await relayer.waitForStatus({
453-
id: taskId,
454-
timeout: 60000
455-
});
456-
457-
console.log('Transaction completed:', status);
545+
if (error instanceof SimulationFailedRpcError) {
546+
console.error('Simulation reverted:', error.message);
547+
console.error('Revert data:', error.revertData);
548+
// error.params contains the original { to, chainId, data } sent
458549
}
459550
}
460551
```
461552

553+
Properties on `SimulationFailedRpcError`: `revertData`, `params`, `code` (`4211`).
554+
555+
### Automatic Fallback on Timeout
556+
557+
When `sendTransactionSync` rpc request times out, the SDK automatically falls back to polling for the transaction status. If you see a warning message like:
558+
559+
```
560+
Transaction 0x... sync call timed out, falling back to polling for completion. DO NOT RETRY this transaction.
561+
```
562+
563+
This means your transaction was successfully submitted but the sync method timed out. The SDK will continue polling for completion automatically. **Do not retry the operation** as this could result in duplicate transactions.
564+
565+
### Recovery Strategies
566+
567+
If a timeout occurs:
568+
1. **Wait for automatic fallback**: `sendTransactionSync` automatically polls after timeout
569+
2. **Check status manually**: Use `getStatus({ id })` to check if transaction is still processing if needed
570+
3. **Retry with longer timeout**: Increase `timeout` and call `waitForReceipt` again
571+
4. **Use async methods**: Switch to async pattern for more control
572+
573+
462574
### Configuration Limits
463575

464576
The SDK enforces the following limits to prevent denial of service:
465577

466578
```typescript
467579
import {
580+
TimeoutError,
581+
TransactionRevertedError,
582+
SimulationFailedRpcError,
468583
MIN_TIMEOUT,
469584
MAX_TIMEOUT,
470585
MIN_POLLING_INTERVAL,
@@ -508,6 +623,9 @@ See the [`/examples`](./examples) directory for complete working examples:
508623
- [`examples/relayer/sponsored`](./examples/relayer/sponsored) - Direct relayer usage
509624
- [`examples/account/sponsored`](./examples/account/sponsored) - Gelato smart account
510625
- [`examples/bundler/sponsored`](./examples/bundler/sponsored) - ERC-4337 bundler
626+
- [`examples/relayer/ws`](./examples/relayer/ws) - Relayer WebSocket usage
627+
- [`examples/bundler/ws`](./examples/bundler/ws) - Bundler WebSocket usage
628+
- [`examples/account/ws`](./examples/account/ws) - Account WebSocket usage
511629

512630
Run an example:
513631

0 commit comments

Comments
 (0)