ethereum-optimism / ethereum-optimism/actions
Stream per-tx progress events from wallet send/sendBatch
- Dominant language
- TypeScript
- Stars
- 32
- Forks
- 26
- Avg merge
- 10h 20m
- Merged PRs (30d)
- 16
Description
## Problem
Multi-tx wallet operations (`approval → permit2 → swap`, `approval → position`) take 5-15s on L2s and produce no observable progress. Consumers — the demo frontend, the demo backend, and downstream subprocess consumers like the Opie Slack bot — all want to surface intermediate status (`"approval submitted"`, `"approval mined"`, `"swap submitted"`, …) instead of leaving the user staring at a long silence followed by the final result.
## Proposal
Add an optional `onProgress` callback to the abstract `Wallet.send` and `Wallet.sendBatch` methods. Fire it at the two natural lifecycle boundaries that already exist inside the wallet implementations:
- **`submitted`** — right after `walletClient.sendTransaction(tx)` (or `bundlerClient.sendUserOperation`) returns a hash.
- **`mined`** — right after `publicClient.waitForTransactionReceipt({ hash })` (or `waitForUserOperationReceipt`) resolves.
No changes to the call surface for non-streaming callers — omit `onProgress` and behavior is identical to today.
### Event shape
```ts
type TransactionStage = 'submitted' | 'mined'
interface TransactionProgressEvent {
stage: TransactionStage
index: number // 0-based; 0 for single send and for smart-wallet sendBatch
total: number // 1 for single send; N for EOA sendBatch; 1 for smart-wallet sendBatch
hash: Hex // tx hash on EOA, userOp hash on smart wallet
label?: string // 'approval' | 'permit2' | 'swap' | 'position', etc.
}
type TransactionProgressHandler = (event: TransactionProgressEvent) => void
```
### Method signatures
```ts
abstract send(
transactionData: TransactionData,
chainId: SupportedChainId,
options?: { onProgress?: TransactionProgressHandler; label?: string },
): Promise
abstract sendBatch(
transactionData: readonly TransactionData[],
chainId: SupportedChainId,
options?: { onProgress?: TransactionProgressHandler; labels?: readonly string[] },
): Promise
```
### EOA semantics
`EOAWallet.send` fires one `submitted` (after `sendTransaction`) + one `mined` (after `waitForTransactionReceipt`). `EOAWallet.sendBatch` loops over `send` and emits the pair per sub-tx with `index` advancing.
### Smart-wallet semantics
A smart-wallet `sendBatch` bundles every sub-tx into one UserOperation. The bundler packs that UserOp into one bundle; the entrypoint executes the calls atomically inside one L2 block. They share a block, a transaction hash, and a status. So smart-wallet `sendBatch` emits exactly **one** `submitted` (bundler accepts the UserOp) + **one** `mined` (UserOp receipt arrives), with `index: 0, total: 1`. Per-sub-tx events would carry no new information.
### Namespace plumbing
- Add `onProgress?: TransactionProgressHandler` to `WalletSwapParams` and `LendOpenPositionBaseParams`.
- `WalletSwapNamespace.dispatch` and `WalletLendNamespace.dispatch` forward the callback into `executeTransactionBatch` along with a `labels[]` parallel to the txs array. Labels are assigned by the namespace at the point of assembly:
```ts
// WalletSwapNamespace.dispatch
const txs: TransactionData[] = []
const labels: string[] = []
if (transactionData.tokenApproval) {
txs.push(transactionData.tokenApproval); labels.push('approval')
}
if (transactionData.permit2Approval) {
txs.push(transactionData.permit2Approval); labels.push('permit2')
}
txs.push(transactionData.swap); labels.push('swap')
return executeTransactionBatch(this.wallet, txs, chainId, { onProgress, labels })
```
`executeTransactionBatch(wallet, txs, chainId, options?)` passes through to `wallet.send` (single) or `wallet.sendBatch` (multi), zipping `labels[i]` to each sub-call's `label`.
## Component map
```
consumer ── calls ──► WalletSwapNamespace.execute (or .lend.openPosition)
│
▼
executeTransactionBatch(wallet, txs, chainId, {onProgress, labels})
│
▼
wallet.send / wallet.sendBatch (per the wallet impl)
│
fires onProgress at:
│
(submitted) after sendTransaction returns hash
(mined) after waitForTransactionReceipt resolves
```
The polling itself happens inside viem's `PublicClient.waitForTransactionReceipt`, configured via `ChainManager`'s `pollingInterval`. The `Wallet` does not poll; it only fires the events at the boundary between viem's two calls.
## Consumer integration
The SDK exposes a single callback parameter. The transport from emitter to UI is the consumer's choice and depends on where the wallet runs. Three consumer integrations land as part of this work, each in its own scope:
### Demo frontend wallets (Dynamic, Turnkey) — in-browser
Wallet runs in-browser via `@eth-optimism/actions-sdk/react`. Components in `EarnWithFrontendWallet.tsx` call `wallet.swap.execute(quote)` directly. `Home.tsx` already keeps a `progressBarData` state. Wiring is a direct function callback into React state — no transport layer needed:
```tsx
const onProgress = useCallback((e) => {
setProgressBarData({ label: e.label, stage: e.stage, step: e.index + 1, total: e.total })
}, [])
await wallet.swap.execute({ ...quote, onProgress })
```
### Demo backend wallet (Privy server wallet) — SSE
Wallet runs server-side; called from an Express route. `EarnWithServerWallet` invokes via `actionsApi.executeSwap`. To surface events to the browser the route must stream — SSE is the smallest change:
```ts
// backend/src/services/swap.ts (route handler)
res.setHeader('Content-Type', 'text/event-stream')
const onProgress = (e) => res.write(`data: ${JSON.stringify(e)}\n\n`)
const result = await wallet.swap.execute({ ...params, onProgress })
res.write(`event: result\ndata: ${JSON.stringify(result)}\n\n`)
res.end()
```
Frontend `actionsApi.executeSwap` switches from `fetch().then(r => r.json())` to an `EventSource` subscription that demultiplexes `onProgress` events and a final `result` event.
### CLI — NDJSON on stderr
`runWalletSwapExecute` and `runWalletLendOpen` write progress events as NDJSON to stderr:
```ts
const onProgress = (e) => process.stderr.write(JSON.stringify(e) + '\n')
await wallet.swap.execute({ ...params, onProgress })
```
stderr is chosen because stdout carries the structured CLI output envelope (`printOutput('swapExecute', ...)`). Mixing them would corrupt machine-parsed CLI output. NDJSON-on-stderr is a common convention (npm, cargo, kubectl, aws --debug).
The CLI side ships in this repo. Subprocess consumers (e.g. Opie) read the stderr stream — line-buffer, try `JSON.parse`, treat lines with a `stage` field as progress events and any other lines as ordinary log/error output. Consumer-side wiring lives in those consumers' own repos and is not part of this issue.
## Frontend vs backend wallet — what differs
The SDK code path is identical; only the consumer's transport changes:
| Aspect | Frontend wallet | Backend wallet |
|---|---|---|
| `onProgress` signature | identical | identical |
| Transport from emitter to UI | none — same JS context | needs SSE / WS / NDJSON |
| Smart-wallet granularity | 1 event pair per `sendBatch` (UserOp) | same |
| Hosted-signer prompts (Privy embedded, Turnkey iframe) | modals before submit; `onProgress` fires after signing | n/a, signing happens server-side |
## Considered and rejected
### Splitting `send` into `submit` + `waitForReceipt`
A two-method API where the caller drives both steps — allows pipelined submission of dependent txs with sequential nonces queued in mempool, which can roughly halve EOA wall time for a 3-tx swap. Rejected because:
- The wall-time win evaporates for smart wallets — a `sendBatch` is one UserOperation already; nothing to pipeline.
- Failure semantics get messy: a revert in tx N invalidates queued tx N+1, N+2, producing cascading reverts.
- API surface change is larger and breaks current callers.
- For the visibility use cases driving this issue, the limiting factor is *visibility*, not wall time.
If we stay on EOA wallets long-term and want to shave wall time, revisit.
### Adding an `included` (in-block, pre-receipt) stage
Possible by polling `eth_getTransactionByHash` separately and watching for a non-null `blockNumber`. Doubles RPC traffic per tx. On the L2s currently targeted, the gap between `included` and `mined` is sub-second, so the event would carry no useful information. Could be added later as opt-in if we ever target L1 mainnet.
### Lifting labels into provider return shapes
Instead of namespaces assigning labels, providers could return `{label, tx}[]` (e.g. `swapTx.steps`). Cleaner long-term and scales as new tx types appear, but it's a separate, larger refactor that touches the provider API, every provider implementation, every test, every mock, and every external caller of `transactionData.tokenApproval / .permit2Approval / .swap`. Out of scope here; worth a follow-up issue.
## Acceptance criteria
### SDK
- [ ] `Wallet.send` and `Wallet.sendBatch` accept an optional `{ onProgress, label / labels }` parameter.
- [ ] EOA path emits one `submitted` + one `mined` per sub-transaction.
- [ ] Smart-wallet path emits exactly one `submitted` + one `mined` per call (single or batch).
- [ ] `WalletSwapParams.onProgress` and `LendOpenPositionBaseParams.onProgress` thread through namespaces and `executeTransactionBatch`.
- [ ] Existing callsites (no `onProgress` passed) compile and behave identically.
- [ ] Tests cover: EOA single send, EOA batch (N events × 2 stages), smart-wallet single, smart-wallet batch (1 event pair).
### CLI
- [ ] `runWalletSwapExecute` and `runWalletLendOpen` write NDJSON progress events to stderr.
- [ ] stdout output envelope is unchanged (no progress events leak into stdout).
### Demo frontend
- [ ] `EarnWithFrontendWallet` wires `onProgress` into the existing `progressBarData` state — Dynamic and Turnkey flows show progress.
### Demo backend
- [ ] `executeSwap` (and lend equivalent) route streams via SSE.
- [ ] `actionsApi.executeSwap` consumes via `EventSource` — Privy server-wallet flow shows progress.
## Out of scope (downstream)
Subprocess consumers of the CLI's stderr (Opie, etc.) live in their own repos and are tracked separately. This issue ships the producer side (SDK callback + CLI NDJSON emission); each consumer wires up its own handler.
## Note on naming — see #370
#370 ("Refactor: rename send/sendTokens (viem alignment)") will rename `wallet.send()` → `wallet.sendTransaction()` and `wallet.sendBatch()` → `wallet.sendBatchTransactions()`. These are exactly the touchpoints this issue extends with `onProgress`. The two issues are independent — whichever lands second does a small textual rebase to match.
Contributor guide
Research direction
Start with the abstract Wallet.send/sendBatch methods, executeTransactionBatch, and the EOA and smart-wallet implementations, then trace WalletSwapNamespace.dispatch and WalletLendNamespace.dispatch. Review the mentioned EarnWithFrontendWallet.tsx, backend/src/services/swap.ts, actionsApi.executeSwap, runWalletSwapExecute, and runWalletLendOpen entry points. Done means SDK tests cover both wallet paths and the CLI, frontend, and backend integrations preserve existing output while exposing progress events.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- express, node.js, react, typescript
- Domain
- api, cli, full-stack, testing
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 38/100