LazorKit LogoLazorKit
React SDK

Sending transactions

When a send resolves, what isSigning means, how passkey transactions are sequenced, the deferred window, callbacks, and what the paymaster does.

Applies to @lazorkit/wallet 3.1.0 and later (deferred window: 3.2.0; 3014 attribution: 3.2.1; callbacks and 4018: 3.3.0). Earlier releases resolved as soon as the paymaster answered.

A send resolves once its transaction is confirmed

signAndSendTransaction, signAndSendWithSession, signAndSendWithAuthority, authorizeAndExecute, authorizeDeferred, executeDeferred, LazorkitWalletAdapter.sendTransaction and the Wallet Standard signAndSendTransaction all resolve with the signature only once the transaction is confirmed, and reject if it failed on chain. The paymaster's answer is not enough: a relayer that answers once the RPC accepted a transaction answers before it ran.

'use client';
import { useWallet, TransactionFailedError, TransactionOutcomeUnknownError } from '@lazorkit/wallet';
import { SystemProgram, PublicKey } from '@solana/web3.js';

export function PayButton({ to, lamports }: { to: string; lamports: number }) {
  const { vaultPubkey, isSigning, signAndSendTransaction } = useWallet();

  const pay = async () => {
    if (!vaultPubkey) return;
    try {
      const signature = await signAndSendTransaction({
        instructions: [SystemProgram.transfer({ fromPubkey: vaultPubkey, toPubkey: new PublicKey(to), lamports })],
      });
      console.log('confirmed', signature);
    } catch (e) {
      if (e instanceof TransactionOutcomeUnknownError) {
        // It may have landed. Check e.signature (or the balance) before offering a retry.
        console.warn('outcome unknown', e.signature);
      } else if (e instanceof TransactionFailedError) {
        console.error('failed on chain', e.logs);
      } else {
        throw e;
      }
    }
  };

  return <button onClick={pay} disabled={isSigning}>Pay</button>;
}

Every error a send can reject with, and what to do about it: Errors › Sending a transaction.

isSigning stays true until the transaction is confirmed

isSigning (and isLoading) is true from the passkey prompt until the transaction is confirmed or fails, up to the two-minute confirmation limit (ConfirmationTimeoutError). While it is true, another call to a hook method rejects at once with Error('Already signing').

  • Disable send buttons while isSigning.
  • To send twice, await the first call, then make the second.
  • connect has its own flag, isConnecting.

One passkey, one transaction at a time

A passkey signature commits to the passkey's counter, read before the prompt opens. If it is read before the previous transaction has executed, the new signature reuses the counter and LazorKit rejects it with SignatureReused (3006), after the user approved. The SDK prevents that within a page:

  • Signatures for one passkey are prepared one at a time: through the hook, the LazorkitWalletAdapter and the Wallet Standard wallet alike. The adapter and Wallet Standard queue a second call; the hook refuses it with "Already signing".
  • Each challenge is read at confirmed from an RPC node that has executed the passkey's previous transaction (minContextSlot). A load-balanced node that is behind answers "not there yet" and is asked again.
  • That slot, and a send whose outcome is not known yet, are kept in localStorage (the slot for ten minutes), so a reload or a second tab of the app starts from them.
  • A call made while the passkey's previous transaction still has no known outcome rejects with PreviousTransactionPendingError and signs nothing.

Two tabs or devices that sign with the same passkey at the same moment are not serialised, and one of them can fail with SignatureReusedError. The SDK never resends such a signature and never opens a new prompt on its own: ask the user to approve again.

Callbacks

Every action of useWallet() and of the store (useWalletStore) takes onSuccess and onFail: in its payload, or for connect, disconnect, signMessage and removeAuthority in its options. Exactly one of them runs per call, and it agrees with the promise: onSuccess with what the promise resolves with, onFail with the error it rejects with.

  • It runs once the action is over, with isSigning (or isConnecting for connect) already false, right before the promise settles. So a send started from onSuccess runs, as one made on the line after await does.
  • A refusal calls onFail too ("Already signing", "No wallet connected", "Already connecting"). One because another call is running is reported at once, while that call still holds the flag, and leaves error (that call's) alone; "No wallet connected" sets error.
  • What a callback throws is logged and changes nothing: a transaction that landed is never reported as failed, onFail is not called for it, and a throwing onFail does not replace the error.
  • disconnect leaves isSigning to an action still running, which goes on to its end and its callbacks; until then a new action is refused with "Already signing".
import { useWallet } from '@lazorkit/wallet';
import type { TransactionInstruction } from '@solana/web3.js';

export function useTwoStepSend() {
  const { signAndSendTransaction } = useWallet();
  return (first: TransactionInstruction[], second: TransactionInstruction[]) =>
    signAndSendTransaction({
      instructions: first,
      // Runs after isSigning is false: this second send is not refused.
      onSuccess: () => void signAndSendTransaction({ instructions: second }),
      onFail: (error) => console.warn('first send failed', error),
    });
}

LazorkitWalletAdapter and the Wallet Standard wallet call each connect, disconnect or change listener on its own and log what one throws: it neither stops the listeners after it nor fails a connect that has happened. The React Native SDK keeps the same contract.

Changed in 3.3.0

In 3.2.1 the store's callbacks ran before isSigning was cleared: a send started from onSuccess was refused with "Already signing", and a throwing onSuccess made a landed transaction reject and call onFail. Refusals did not call onFail, and disconnect, removeAuthority and signMessage took no callbacks.

Transaction options

transactionOptions fieldOn web (3.3.0)
addressLookupTableAccountsUsed. Forces the v0 wire format.
txVersion'v0' (default) or 'legacy'. Legacy with lookup tables throws.
clusterSimulationPassed to the portal's preview for signAndSendTransaction.
computeUnitLimitIgnored: accepted by the types, never read.
feeTokenIgnored: accepted by the types, never read.

connect({ feeMode }) is ignored on web too.

How much fits in one transaction

A passkey Execute leaves about 345 bytes for inner instructions without a lookup table (v0, the portal in an iframe; about 40 more from a popup). Chrome sometimes pads the passkey's clientDataJSON, which costs 109 of them. A Jupiter swap usually does not fit: a single-hop SOL→USDC route with Jupiter's lookup tables and compute-budget instructions came to 1245 bytes. Measured 2026-09-29 with @lazorkit/sdk-legacy 1.2.0.

Pass your lookup tables in addressLookupTableAccounts. The portal's preview is compiled without them whenever it fits in a packet, so the portal sees every account; only a payload over 1232 bytes is previewed with them. Anything larger goes through deferred execution.

Deferred execution

authorizeAndExecute (one call) and authorizeDeferred + executeDeferred (two steps) split a large payload in two transactions: TX1 Authorize records hashes of the instructions, signed by the passkey; TX2 ExecuteDeferred carries the instructions and needs no passkey. TX2 leaves about 825 bytes for inner instructions.

The window. The program accepts TX2 for expiryOffset slots after the slot TX1 landed in: 10 to 9000, default 1500 (DEFAULTS.DEFERRED_EXPIRY_SLOTS). Outside that range the call throws a RangeError before the prompt.

  • A slot's length depends on the cluster and its load (devnet ran near 230 ms in September 2026, mainnet near 400 ms). Do not convert it to minutes with a fixed figure; 9000 slots is tens of minutes, not hours.
  • The window starts at TX1's slot, not when authorizeDeferred resolves. The wallet may wait up to two minutes for TX1 to confirm before it sends TX2, so do not pass a small value.
  • The window is also how long an unused approval stays executable. Nothing can cancel an authorization before it expires: anyone holding the payload can send TX2 until then.

When it expires. An authorization that has already expired is not sent, and a paymaster's 3014 is not retried. The call rejects with DeferredExpiredError: nothing in the payload ran, the passkey approval is spent, and deferredExecPda still holds the paymaster's rent until the Authorize payer (the paymaster's fee payer) closes it with ReclaimDeferred. Only that payer can, and only after the window.

A 3014 is reported as DeferredExpiredError only when it is the authorization's own: its logs name LazorKit as the first program to fail, it landed after expires_at, or the chain is past expires_at when the wallet reads the account again (at processed). An inner program's 3014 (Anchor's AccountNotAssociatedTokenAccount) is thrown as it came. Any error from sending TX2 carries deferredExecPda, authorizeSignature (when the call sent TX1) and expiresAtSlot: the DeferredFailureContext fields.

import { useWallet, DeferredExpiredError } from '@lazorkit/wallet';

export function useSwap() {
  const { authorizeAndExecute } = useWallet();
  return async (instructions: Parameters<typeof authorizeAndExecute>[0]['instructions']) => {
    try {
      return await authorizeAndExecute({ instructions, expiryOffset: 3000 });
    } catch (e) {
      if (e instanceof DeferredExpiredError) {
        // Nothing ran. Ask the user to approve again.
        console.warn('expired; rent held at', e.deferredExecPda.toBase58());
        return null;
      }
      throw e;
    }
  };
}

executeDeferred on another device or a server: a server has no hook. Use LazorKitClient.executeDeferredFromPayload from @lazorkit/sdk-legacy with the deserialized payload; see Session keys › Deferred execution.

The paymaster

  • Every send goes to the paymaster as a Kora JSON-RPC signAndSendTransaction with { transaction, signer_key }. apiKey is sent as x-api-key. It ships inside your bundle, so it is not a secret: limit what it allows on the relayer side.
  • The wallet waits for confirmation whatever the relayer answers. Kora confirms before it answers by default, so the wait costs one status read. A relayer that answers early (Kora with respond_after "sent") makes the wallet poll.
  • A send request may take 90 s; other paymaster calls 30 s.
  • A send that fails is resent with the same signed bytes, up to 3 attempts in all (1 s, then 2 s apart), so the transaction can land at most once. That includes a lost answer (no answer within 90 s, a network error, HTTP 5xx or 408): if a later attempt gets an answer, the call goes on as usual.
  • Not resent: LazorKit's 3006 and 3014 (unless an earlier attempt may have been sent), a 4018 (RetiredDeployment), a failure that names the transaction's signature (that signature is followed instead), and bytes the paymaster reports as already processed. A 4018 fails on the first answer, with V1WalletRetiredError for a retired v1 wallet; see Errors › v1 and v2.
  • If every attempt fails and one of them may have been sent, or the bytes were already processed, the call rejects with TransactionOutcomeUnknownError. The React Native SDK does not resend at all.
  • v1 wallets go to v1PaymasterConfig. See Migrating from v1.

Changed in 3.3.0

3.2.1 resent a 4018 like other failures, three attempts 1 s and 2 s apart.

More on running a relayer: Paymaster › Using a paymaster with the SDKs.