Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

TransactionButton

Multi-step transaction flow component - from simulation to on-chain confirmation. Handles approval flows, anti-phishing safety, signature steps, and error recovery.

Bundle: 6.5 kB JS (gzip) + 2.1 kB CSS (gzip). Includes the flow store, simulation, decoded calldata preview, and risk-confirm UI used by FlowSteps / FlowProgress / FlowToast.

Quick Start

import { TransactionButton, txStep } from '@txkit/react'
import { parseEther } from 'viem'
 
// Simple ETH transfer
<TransactionButton
  steps={[ txStep('send', 'Send ETH', { to: '0x...', value: parseEther('1') }) ]}
  label="Send 1 ETH"
  onFlowComplete={(results) => console.log('Done!', results)}
/>

Approve + Execute (ERC-20)

import { TransactionButton, approveAndExecute } from '@txkit/react'
import { parseUnits } from 'viem'
 
<TransactionButton
  steps={approveAndExecute({
    token: USDC_ADDRESS,
    spender: ROUTER_ADDRESS,
    amount: parseUnits('100', 6),
    tx: {
      address: ROUTER_ADDRESS,
      abi: routerAbi,
      functionName: 'swap',
      args: [ USDC_ADDRESS, WETH_ADDRESS, parseUnits('100', 6), minOut ],
    },
  })}
  label="Swap 100 USDC"
/>

Three Tiers of Customization

Tier 1: Zero-Config

<TransactionButton
  steps={[ txStep('send', 'Send', { to: '0x...', value: parseEther('1') }) ]}
/>

Tier 2: Custom Render

<TransactionButton
  steps={[ txStep('send', 'Send', { to: '0x...', value: parseEther('1') }) ]}
>
  {({ flow, currentStep, start, retry, reset, explorerUrl }) => (
    <div>
      <p>Status: {flow.status}</p>
      {flow.status === 'idle' && <button onClick={start}>Send</button>}
      {flow.status === 'error' && <button onClick={retry}>Retry</button>}
      {flow.status === 'completed' && <button onClick={reset}>Done</button>}
      {explorerUrl && <a href={explorerUrl}>View on Explorer</a>}
    </div>
  )}
</TransactionButton>

Tier 3: Headless Hook

import { useTransactionFlow, txStep } from '@txkit/react'
import { parseEther } from 'viem'
 
const { flow, start, retry, reset } = useTransactionFlow({
  steps: [ txStep('send', 'Send', { to: '0x...', value: parseEther('1') }) ],
  onFlowComplete: (results) => console.log('All steps done!', results),
  onStepError: (error, stepId) => console.error(`Step ${stepId} failed:`, error.message),
})

Props

PropTypeDefaultDescription
stepsFlowStep[]requiredStep definitions for this transaction flow
classNamestring-CSS class
children(data: RenderData) => ReactNode-Custom render function
data-testidstring-Test ID for automated testing
flowIdstring'__default__'Flow ID for parallel flows
labelstring"Send"Button text
descriptionstring-Optional summary text shown above the button
labelsPartial<Labels>-UI text overrides
chainIdnumbercurrent chainTarget chain (auto-switches)
safetyPartial<SafetyConfig>-Anti-phishing config
confirmationsnumber1Block confirmations to wait
resetDelaynumber0Auto-reset after success (ms)
disabledbooleanfalseDisable button
showExplorerLinkbooleantrueShow explorer link
onFlowComplete(results: Record<string, StepResult>) => void-Called when entire flow completes
onStepComplete(stepId: string, result: StepResult) => void-Called on any step completion
onError(error: TransactionError, stepId: string) => void-Called on any step error
onFlowStatusChange(status: FlowStatus) => void-Called on every flow status change

Step Definitions

Transaction Step (on-chain)

txStep('approve', 'Approve USDC', {
  address: USDC_ADDRESS,
  abi: erc20Abi,
  functionName: 'approve',
  args: [ SPENDER, amount ],
})

Dynamic Step Arguments

The tx argument of txStep(id, label, tx) is TxParams | (ctx: StepContext) => TxParams | Promise<TxParams>. Use the function form when step inputs aren't known at flow definition time - e.g. they depend on a prior step's receipt, an off-chain quote fetched before flow start, or a runtime eth_call against the connected chain.

The same pattern applies to signData, shouldSkip, waitForCondition, onStart, onError, and onCancel.

Pattern 1: Closure (off-chain data fetched before flow)

When the args depend on data fetched once before the flow starts (a swap quote, an order id, a nonce), capture it in a closure:

const SwapButton = () => {
  const [ steps, setSteps ] = useState<FlowStep[] | null>(null)
 
  const prepareSwap = async () => {
    const quote = await fetchQuote({ tokenIn: USDC, tokenOut: WETH, amount })
    setSteps([
      txStep('swap', 'Swap', {
        to: SWAP_ROUTER,
        data: quote.calldata,
        value: quote.value,
      }),
    ])
  }
 
  if (!steps) {
    return <button onClick={prepareSwap}>Get quote</button>
  }
  return <TransactionButton steps={steps} label="Swap" />
}

Pattern 2: Inter-step (depends on prior step result)

StepContext exposes previousResult (last step) and results[stepId] (any completed step by id). A swap step that needs the receipt of a prior approve:

txStep('swap', 'Swap', (ctx) => ({
  to: SWAP_ROUTER,
  data: encodeSwapCalldata({
    approveBlock: ctx.results['approve'].receipt.blockNumber,
    minOut: computeMinOut(ctx.results['approve']),
  }),
  value: 0n,
}))

Pattern 3: Async runtime read

Need an on-chain read at step start? Return a Promise<TxParams> and use ctx.publicClient for the read:

txStep('claim', 'Claim rewards', async (ctx) => {
  const pending = await ctx.publicClient.readContract({
    address: REWARDS_VAULT,
    abi: rewardsAbi,
    functionName: 'pendingRewards',
    args: [ ctx.address ],
  })
  return {
    address: REWARDS_VAULT,
    abi: rewardsAbi,
    functionName: 'claim',
    args: [ pending ],
  }
})

When to combine with shouldSkip

If the dynamic computation also decides whether the step should run at all, pair it with shouldSkip. The skip check runs first - if it returns true, the tx factory is never invoked:

txStep('stake', 'Stake', async (ctx) => buildStakeTx(ctx), {
  shouldSkip: (ctx) => !autoStake,
})

Signature Step (off-chain)

import { signAndSubmit } from '@txkit/react'
 
signAndSubmit({
  id: 'place-order',
  label: 'Place Order',
  signData: {
    method: 'eth_signTypedData_v4',
    domain: orderDomain,
    types: orderTypes,
    value: orderData,
  },
  onSign: async (signature, ctx) => {
    const orderId = await submitOrder(signature)
    return { data: { orderId } }
  },
  waitForCondition: async (ctx, signal) => {
    await pollUntil(() => checkOrderFilled(orderId), { signal })
  },
})

Flow Helpers

HelperDescription
txStep(id, label, tx, options?)Create a single transaction step
approveAndExecute({ token, spender, amount, tx })ERC-20 approve + action (handles USDT approve-to-zero)
multiApproveAndExecute({ approvals, tx })Multiple token approvals + action
signAndSubmit({ id, label, signData, onSign })Off-chain signature + submission

Compound Components

FlowSteps, FlowProgress, and FlowToast read flow state from TxKitProvider context - they are siblings of TransactionButton, not children. Place each one in the part of the layout where it makes UX sense (progress in the header, step list near the action, toast in a portal).

<TxKitProvider config={config}>
  <header>
    <FlowProgress showSummary />
  </header>
 
  <main>
    <FlowSteps orientation="vertical" />
    <TransactionButton
      steps={approveAndExecute({ token: USDC, spender: ROUTER, amount, tx: swapTx })}
      label="Swap"
      description="Approve USDC, then swap for WETH on Uniswap v4"
    />
  </main>
 
  <FlowToast position="bottom-right" autoDismiss={5000} />
</TxKitProvider>

For multiple parallel flows in one tree (rare), set the same flowId on the button and each compound component to scope them together:

<TransactionButton flowId="swap" steps={swapSteps} />
<FlowSteps flowId="swap" />
 
<TransactionButton flowId="stake" steps={stakeSteps} />
<FlowSteps flowId="stake" />

Parallel Flows

Multiple TransactionButton instances can run simultaneously. Pass a unique flowId (or rely on the auto-generated id) and use that same id with compound components like FlowSteps. Each flow has its own independent state.

import { TransactionButton, FlowSteps } from '@txkit/react'
 
const ApproveAndSwap = () => (
  <>
    <TransactionButton flowId="approve" steps={approveSteps} />
    <TransactionButton flowId="swap" steps={swapSteps} />
 
    <FlowSteps flowId="approve" />
    <FlowSteps flowId="swap" />
  </>
)

The useFlowStore() hook from @txkit/react exposes the global flow registry as a Map of flowId -> FlowEntry (flow state, steps, actions). Use it when you need to read or react to multiple flows from a single component.

Step Context

Inside a step's tx(), prepare(), onSuccess(), or other lifecycle callback, you receive a StepContext:

type StepContext = {
  results: Record<string, StepResult>
  previousResult: StepResult | undefined
  address: `0x${string}`
  chainId: number
  publicClient: PublicClient
}
 
type StepResult =
  | { type: 'tx'; hash: `0x${string}`; receipt: TransactionReceipt }
  | { type: 'sign'; signature: `0x${string}` }

Use ctx.previousResult for the immediately preceding step or ctx.results[stepId] to chain by id - e.g., a swap step might read the receipt of the prior approve step.

Approve + Execute Auto-Skip

approveAndExecute({ token, spender, amount, tx }) returns a two-step flow: approve then tx. Before running, the approve step calls shouldSkip(ctx) and reads on-chain allowance(owner, spender) - if the existing allowance already covers amount, the approve step is marked skipped and the flow jumps straight to tx. No second wallet popup, no wasted gas.

USDT (and a handful of legacy tokens) revert when calling approve(spender, X) while a non-zero allowance is set. approveAndExecute detects this and auto-inserts a reset step (approve(spender, 0)) before the real approve. The reset is itself skipped when the current allowance is already zero.

<TransactionButton
  steps={approveAndExecute({
    token: USDT_ADDRESS,
    spender: ROUTER_ADDRESS,
    amount: parseUnits('100', 6),
    tx: { address: ROUTER_ADDRESS, abi: routerAbi, functionName: 'swap', args },
  })}
  label="Swap 100 USDT"
/>

Use multiApproveAndExecute({ approvals: [...], tx }) for flows that need multiple approvals (e.g. swapping two tokens for a third).

Anti-Phishing Confirmation Delay

For high-value transactions, force the user to wait before the wallet popup opens. safety.delayMs shows a countdown on the button, and the user can cancel during the delay - the wallet is never invoked.

<TransactionButton
  steps={[ txStep('send', 'Send', { to: recipient, value: parseEther('10') }) ]}
  safety={{ delayMs: 5000 }}
  label="Send 10 ETH"
/>

Pair with safety.riskProvider to block on red flags (Blowfish/Blockaid) and safety.warnMaxApproval (on by default) to highlight MAX_UINT256 approvals at the review step.

Error Handling

onError(error, stepId) fires for every step failure. Branch on error.code to surface different UX for user-rejected popups vs network failures vs contract reverts.

<TransactionButton
  steps={[ txStep('send', 'Send', { to, value }) ]}
  onError={(error, stepId) => {
    if (error.code === 'USER_REJECTED') {
      // Soft toast - the user closed the wallet, not a failure
      toast.info('Cancelled')
      return
    }
    if (error.code === 'INSUFFICIENT_FUNDS') {
      toast.error('Not enough balance to cover gas')
      return
    }
    if (error.code === 'EXECUTION_REVERTED') {
      toast.error(`Transaction reverted: ${error.reason ?? 'unknown'}`)
      return
    }
    captureException(error, { extra: { stepId } })
  }}
/>

Common error.code values: USER_REJECTED, INSUFFICIENT_FUNDS, EXECUTION_REVERTED, SIMULATION_FAILED, CHAIN_MISMATCH, TIMEOUT, REPLACED, NETWORK, UNKNOWN.

See also

State Machine

Step Statuses (12)

pending → simulating → confirming-risk → (confirm) → signing/tx-pending → waiting → completed
                     → simulation-failed → (retry) → simulating
                                         → (force) → signing/tx-pending
         signing → tx-pending → waiting → completed
                             → error → (retry) → simulating
         → rejected → (retry) → simulating
         → skipped (via shouldSkip)
         → canceled (cascade from failed step)

Flow Statuses (8)

idle | simulating-all | running | paused | completed | error | rejected | canceled

Safety Config

FieldTypeDefaultDescription
simulatebooleantrueSimulate transaction via eth_call + estimateGas before requesting a signature
delayMsnumber0Confirmation countdown in ms shown before sending (anti-phishing friction)
warnMaxApprovalbooleantrueSurface a warning when an approval amount equals MAX_UINT256
riskProviderTransactionRiskProvider | nullnullPluggable pre-sign risk scorer (Blowfish, Blockaid, custom)

Pass a Partial<SafetyConfig> - missing fields fall back to the defaults above.

Risk Provider

type TransactionRiskProvider = {
  assess: (params: {
    to: `0x${string}`
    data?: `0x${string}`
    value?: bigint
    chainId: number
    from: `0x${string}`
  }) => Promise<RiskResult>
}
 
type RiskResult = {
  level: 'safe' | 'low' | 'medium' | 'high'
  warnings?: string[]
  blocked?: boolean
}
<TransactionButton
  steps={steps}
  safety={{
    delayMs: 5000,
    riskProvider: {
      assess: async ({ to, data, value, chainId, from }) => {
        const result = await blowfishApi.scan({ to, data, value, chainId, from })
        return {
          level: result.riskLevel,
          warnings: result.warnings,
          blocked: result.action === 'BLOCK',
        }
      },
    },
  }}
/>

Step Options

All step factories (txStep, signAndSubmit, etc.) accept a shared set of options on the step object:

OptionTypeDescription
descriptionstringSub-label rendered below the main label in FlowSteps
shouldSkip(ctx: StepContext) => boolean | Promise<boolean>Skip at runtime (e.g. allowance already sufficient). Re-evaluated on retry
waitAfterMsnumberDelay in ms after completion before starting the next step
waitForCondition(ctx: StepContext, signal: AbortSignal) => Promise<void>Async condition to satisfy before the step is marked complete
optionalbooleanStep can be skipped by the user via skipStep() after an error
onStart(ctx: StepContext) => voidCalled when the step starts executing
onError(ctx: StepContext & { error: TransactionError }) => voidCalled when the step fails
onCancel(ctx: StepContext) => Promise<void> | voidCalled when the step is canceled (e.g. cancel a CoW order on backend)

txStep extends with two transaction-only options:

OptionTypeDescription
safetyPartial<SafetyConfig>Per-step safety overrides (defaults to the flow-level safety prop)
gasbigintPer-step gas limit override
onComplete(ctx: StepContext & { hash, receipt }) => voidCalled when the step receipt is mined

Labels

Override any label string by passing a Partial<TransactionButtonLabels> to the labels prop.

KeyDefaultWhere
send'Send'Button - idle state (overridden by label prop)
simulating'Simulating'Button - simulation in progress
confirmingRisk'Review Transaction'Button - risk review
simulationFailed'Simulation Failed'Button - simulation failed
approving'Approving'Button - approve step in progress
awaitingSignature'Confirm in Wallet'Button - waiting for wallet signature
pending'Pending'Button - tx pending on-chain
success'Confirmed'Button - completed
error'Failed'Button - generic error
rejected'Rejected'Button - user rejected in wallet
retry'Try Again'Button - retry after error
confirm'Confirm'Risk dialog - confirm
cancel'Cancel'Risk dialog - cancel
forceSubmit'Send Anyway'Button - submit despite simulation failure
viewOnExplorer'View on Explorer'Explorer link