Sign and send a transaction (the working path)
This is the canonical "I have a wallet, now what?" recipe, the foundation every
other transaction recipe links from. Build a Transaction with sdk-core, sign
with the connected provider, send and track with TransactionManager. The whole
pattern is three calls.
In v4, signing was a React hook (useSignTransactions). In v5, signing is on the
provider itself (provider.signTransactions(txs)), a deliberate change because
the provider already knows the user's signing surface (extension popup, Ledger
USB, WalletConnect mobile prompt). This recipe is the v5-current pattern.
If you do not yet have a working sdk-dapp v5 setup, start with Minimal sdk-dapp v5 in Next.js or Minimal sdk-dapp v5 in Vite, both of which end where this recipe begins.
Prerequisites
- A working sdk-dapp v5 setup (Next.js or Vite, see the start-here recipes).
- A logged-in account on devnet.
- A few devnet EGLD (faucet or devnet wallet).
Install
mkdir sign-and-send
cd sign-and-send
# Create the project files shown on this page.
cp .env.local.example .env.local
npm install
npm run dev
# open https://localhost:3000
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-sign-and-send",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — sign and send a transaction (provider.signTransactions + TransactionManager.send + .track).",
"scripts": {
"dev": "next dev --experimental-https",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.1",
"@multiversx/sdk-dapp": "5.7.1",
"@multiversx/sdk-dapp-ui": "0.1.24",
"@multiversx/sdk-dapp-utils": "3.1.0",
"axios": "1.18.1",
"bignumber.js": "9.3.1",
"next": "15.5.21",
"protobufjs": "7.6.5",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.19.43",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"typescript": "5.9.3"
},
"overrides": {
"postcss": "^8.5.18",
"brace-expansion": "^5.0.9",
"nanoid": "^3.3.17"
},
"engines": {
"node": ">=20.19.0"
}
}
Keep the overrides block when copying this starter. It deliberately pins
patched PostCSS, brace-expansion, and Nano ID transitives used by Next and
sdk-dapp.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// next.config.js — same shape as the nextjs-minimal recipe's next.config.js.
//
// Sign-and-send builds directly on top of the minimal Next.js setup; the
// transitive WalletConnect/Ledger bundler config is identical. See
// the nextjs-minimal recipe's next.config.js for the full annotation.
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@multiversx/sdk-dapp-ui'],
webpack: (config) => {
config.resolve.fallback = { ...(config.resolve.fallback || {}), fs: false };
config.externals.push(
'pino-pretty',
'lokijs',
'encoding',
{
bufferutil: 'bufferutil',
'utf-8-validate': 'utf-8-validate',
},
);
return config;
},
};
module.exports = nextConfig;
// app/layout.tsx — Next.js 15 App Router root layout.
//
// Server-component shell, identical to the nextjs-minimal recipe's app/layout.tsx.
// All of the sdk-dapp-touching code lives in <Providers />.
import type { Metadata, Viewport } from 'next';
import type { ReactNode } from 'react';
import { Providers } from './providers';
export const metadata: Metadata = {
title: 'Cookbook recipe — Sign and send a transaction',
description:
'The canonical sdk-dapp v5 sign + send + track flow. Compiles strict.',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
'use client';
// app/providers.tsx — client-only sdk-dapp v5 init wrapper.
//
// Same shape as the nextjs-minimal recipe's app/providers.tsx. See that file for
// the full annotation; the only thing this recipe adds is that initApp()
// resolving is a hard prerequisite for the send-tx flow (the sdk-dapp store
// must be hydrated before TransactionManager.send() works).
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { initApp } from '@multiversx/sdk-dapp/out/methods/initApp/initApp';
import { UnlockPanelManager } from '@multiversx/sdk-dapp/out/managers/UnlockPanelManager/UnlockPanelManager';
import { dappConfig } from '../lib/multiversx';
let initializationPromise: Promise<void> | undefined;
function initializeDapp(): Promise<void> {
if (!initializationPromise) {
initializationPromise = initApp(dappConfig).then(() => {
// Both callbacks return Promise<void>, as OnCloseUnlockPanelType requires.
UnlockPanelManager.init({
loginHandler: async (): Promise<void> => {
// eslint-disable-next-line no-console
console.log('Logged in.');
},
onClose: async (): Promise<void> => {
// eslint-disable-next-line no-console
console.log('Unlock panel closed.');
},
});
}).catch((cause: unknown) => {
initializationPromise = undefined;
throw cause;
});
}
return initializationPromise;
}
export function Providers({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [ready, setReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let subscribed = true;
void initializeDapp()
.then(() => {
if (subscribed) setReady(true);
})
.catch((cause: unknown) => {
if (!subscribed) return;
setError(
cause instanceof Error
? cause
: new Error('Wallet SDK initialization failed.'),
);
});
return () => {
subscribed = false;
};
}, []);
if (error) {
return (
<main
role="alert"
style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}
>
Wallet SDK initialization failed: {error.message}
</main>
);
}
if (!ready) {
return (
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
Initializing wallet SDK…
</main>
);
}
return <>{children}</>;
}
// lib/multiversx.ts — environment configuration for sdk-dapp's initApp.
//
// Same shape as the nextjs-minimal recipe's lib/multiversx.ts. We keep it
// duplicated rather than cross-imported so each recipe is a self-contained,
// runnable unit.
import { EnvironmentsEnum } from '@multiversx/sdk-dapp/out/types/enums.types';
import { ThemesEnum } from '@multiversx/sdk-dapp/out/types/theme.types';
const WALLET_CONNECT_PROJECT_ID =
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID?.trim();
if (!WALLET_CONNECT_PROJECT_ID) {
throw new Error(
'Set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID in .env.local before starting the app.',
);
}
export const dappConfig = {
storage: {
getStorageCallback: (): Storage => sessionStorage,
},
dAppConfig: {
environment: EnvironmentsEnum.devnet,
nativeAuth: true,
// See node_modules/@multiversx/sdk-dapp/out/methods/initApp/initApp.types.d.ts —
// `theme` is a ThemesEnum member, not a bare string literal.
theme: ThemesEnum.dark,
providers: {
walletConnect: {
walletConnectV2ProjectId: WALLET_CONNECT_PROJECT_ID,
},
},
},
};
export { EnvironmentsEnum };
The recipe is a Next.js app, but the load-bearing file (lib/transactions.ts) is
React-independent, browser-side sdk-dapp code. You can reuse it in another
browser app after initApp() has resolved and a wallet is connected. For a
server action, Remix loader, or CLI, use sdk-core with a server-side signer and
network provider instead; those contexts do not have sdk-dapp's browser store,
storage, or connected-wallet provider.
The three-step pattern
The whole flow lives in lib/transactions.ts. Read this once and you have the v5
transaction pattern.
// lib/transactions.ts — the canonical sign / send / track flow for sdk-dapp v5.
//
// This file demonstrates the three-step pattern that every transaction in a
// v5 dApp follows:
//
// 1. BUILD with sdk-core's Transaction constructor.
// Pull sender, nonce, and chainID from the sdk-dapp
// store via getAccount() and getNetworkConfig() — both are non-React
// synchronous reads.
// 2. SIGN via the connected provider.
// In v5 signing is on the provider, not a hook — this is the v4 → v5
// semantic change documented in the v5 changelog.
// 3. SEND + TRACK with TransactionManager.
// The returned sessionId is the React-hook key for
// the session-aware pending/successful/failed hooks.
//
// The function below is React-independent browser code. It reads the
// initialized sdk-dapp store and connected wallet provider directly (without
// hooks), so call it only after initApp() and browser-wallet login have
// completed. Server and CLI code should use sdk-core with its own signer and
// network provider.
import { Address, Transaction } from '@multiversx/sdk-core';
import { GAS_PRICE, GAS_LIMIT } from '@multiversx/sdk-dapp/out/constants/mvx.constants';
import { TransactionManager } from '@multiversx/sdk-dapp/out/managers/TransactionManager/TransactionManager';
import { getAccount } from '@multiversx/sdk-dapp/out/methods/account/getAccount';
import { getNetworkConfig } from '@multiversx/sdk-dapp/out/methods/network/getNetworkConfig';
import { getAccountProvider } from '@multiversx/sdk-dapp/out/providers/helpers/accountProvider';
import type { SignedTransactionType } from '@multiversx/sdk-dapp/out/types/transactions.types';
/**
* Inputs for an EGLD transfer. Amount is in the smallest denomination —
* 1 EGLD = 10^18.
*
* Use bigint everywhere. Never `Number` — JavaScript loses precision past
* 2^53 and EGLD amounts go higher than that all the time.
*/
export interface SendEgldInput {
/** Bech32 address of the recipient. */
receiver: string;
/** Amount in the smallest denomination (10^18 = 1 EGLD). */
amountInSmallestDenomination: bigint;
/** Optional UTF-8 data field. Note: data adds gas. */
data?: string;
}
/**
* Output of a successful sign + send + track call. The sessionId keys the
* ...TransactionsSessions() maps and identifies the per-session success and
* failure callbacks. The flat hooks (useGetPendingTransactions and friends)
* take no session id and return global arrays.
*/
export interface SendEgldOutput {
sessionId: string;
transactionHash: string;
}
export interface SendEgldCallbacks {
onSuccess: (sessionId: string) => Promise<void>;
onFail: (sessionId: string) => Promise<void>;
}
/**
* Build, sign, send, and start tracking an EGLD transfer.
*
* Throws if no account is connected. The caller is responsible for ensuring
* useGetIsLoggedIn() is true before invoking — the function does not
* gracefully wait for login.
*/
export async function sendEgld(
input: SendEgldInput,
callbacks: SendEgldCallbacks,
): Promise<SendEgldOutput> {
const account = getAccount();
const { network } = getNetworkConfig();
if (!account.address) {
throw new Error('No account connected. Call after a successful login.');
}
// Step 1 — BUILD.
// Note: account.nonce is the network's last-known nonce. If you sent any
// transactions earlier in this session you must increment locally — see
// Pitfall 1 on this page.
//
// The canonical Transaction constructor shape: value/gasLimit/nonce are
// bigint, data is Uint8Array.
const tx = new Transaction({
sender: Address.newFromBech32(account.address),
receiver: Address.newFromBech32(input.receiver),
value: input.amountInSmallestDenomination,
gasLimit: BigInt(computeGasLimit(input.data)),
gasPrice: BigInt(GAS_PRICE),
chainID: network.chainId,
nonce: BigInt(account.nonce),
// Version 1 is the unsigned-transaction default; sdk-dapp managers handle
// hash-signing version bumps internally where needed.
version: 1,
// data is omitted via spread when undefined to play nicely with
// exactOptionalPropertyTypes (strict mode rejects passing
// `undefined` to a property that is `Uint8Array` not `Uint8Array | undefined`).
...(input.data ? { data: new Uint8Array(Buffer.from(input.data)) } : {}),
});
// Step 2 — SIGN.
// The provider knows the connected wallet's signing surface (extension
// popup / Ledger USB / WalletConnect mobile prompt). signTransactions
// returns the same array shape it was given, with each tx now carrying a
// .signature field.
const provider = getAccountProvider();
const [signed] = await provider.signTransactions([tx]);
if (!signed) {
// Defensive: if the user cancelled the wallet popup, signTransactions
// throws or returns an empty array depending on the provider.
throw new Error('User cancelled signing.');
}
// Step 3 — SEND + TRACK.
// TransactionManager.send()'s type signature is shape-symmetric: pass a
// flat Transaction[] (one batch), get a flat SignedTransactionType[] back;
// pass Transaction[][] (multiple batches), get SignedTransactionType[][]
// back (node_modules/@multiversx/sdk-dapp/out/managers/TransactionManager/TransactionManager.d.ts).
// We always pass a single flat batch, so the nested variant cannot occur
// here — narrow explicitly (isFlatSentTransactions below) rather than
// casting, so a real shape change fails loudly instead of miscompiling.
//
// track() takes that SAME array (not a single unwrapped element) — see the
// TransactionManager.d.ts JSDoc example, which calls
// `txManager.track(sentTransactions, {...})` on the whole array.
const txManager = TransactionManager.getInstance();
const sentTransactions = await txManager.send([signed]);
if (!isFlatSentTransactions(sentTransactions)) {
throw new Error('Unexpected response shape from TransactionManager.send().');
}
const [sent] = sentTransactions;
if (!sent) {
throw new Error('Send failed: TransactionManager returned no transactions.');
}
const sessionId = await txManager.track(sentTransactions, {
transactionsDisplayInfo: {
processingMessage: 'Sending EGLD…',
successMessage: 'Transfer complete.',
errorMessage: 'Transfer failed.',
},
onSuccess: callbacks.onSuccess,
onFail: callbacks.onFail,
});
// `SignedTransactionType` already carries `hash: string` (see
// node_modules/@multiversx/sdk-dapp/out/types/transactions.types.d.ts).
// sdk-dapp's own status-polling joins on this same field internally, so
// it is the authoritative hash — no separate computation needed.
//
// Earlier drafts of this recipe called
// `new TransactionComputer().computeTransactionHash(sent)` and hex-encoded
// the result via `Buffer.from(...)`. Both are wrong for this SDK version:
// computeTransactionHash() expects an sdk-core `Transaction` instance, not
// the `SignedTransactionType` plain object `send()` returns here (that's a
// real tsc --strict error, not just a style nit) — and its return value is
// already a hex `string`, not a `Uint8Array`
// (node_modules/@multiversx/sdk-core/out/core/transactionComputer.js), so
// re-wrapping it in `Buffer.from(str).toString('hex')` would silently
// double-encode it into a wrong, longer hex string.
const transactionHash = sent.hash;
return {
sessionId,
transactionHash,
};
}
/**
* Type guard narrowing TransactionManager.send()'s union return type down to
* the flat (single-batch) variant. We only ever call send() with a single
* flat Transaction[] batch, so this should always hold at runtime; it exists
* so the compiler (not an assertion) enforces that invariant.
*/
function isFlatSentTransactions(
value: SignedTransactionType[] | SignedTransactionType[][],
): value is SignedTransactionType[] {
return value.length === 0 || !Array.isArray(value[0]);
}
/**
* Compute a reasonable gas limit for a plain or data-bearing EGLD transfer.
*
* The default GAS_LIMIT (50_000) covers the empty-data case. For data
* transactions, the network charges 1500 gas per byte.
*/
function computeGasLimit(data: string | undefined): number {
const baseLimit = GAS_LIMIT;
if (!data) {
return baseLimit;
}
const dataBytes = Buffer.byteLength(data, 'utf8');
// 1500 gas per byte of data. Add some headroom (10%) so a slight protocol
// change doesn't immediately invalidate every recipe — CI catches drift.
return Math.ceil((baseLimit + dataBytes * 1500) * 1.1);
}
The three steps:
- Build with sdk-core.
Transactionis the value type;Address.newFromBech32()parses the bech32 strings.getAccount()andgetNetworkConfig()are non-React reads of the sdk-dapp store; they work from any context as long asinitApp()has resolved. - Sign with the provider.
getAccountProvider()returns the connected provider; calling.signTransactions([tx])opens the wallet UI and resolves with the same array, now signature-bearing. - Send and track with
TransactionManager. The singleton's.send()POSTs to the network;.track()starts the WebSocket-or-polling tracker that updates the Zustand store as the tx transitions pending to success/fail. The returnedsessionIdis the lookup key for the React hooks.
The React hook
A small lifecycle controller owns the in-flight lock and terminal callbacks. Because it is independent of React, the verification harness can execute the same state machine the UI uses:
import type {
SendEgldCallbacks,
SendEgldInput,
SendEgldOutput,
} from './transactions';
export interface SendEgldState {
status: 'idle' | 'signing' | 'tracking' | 'done' | 'error';
sessionId: string | null;
transactionHash: string | null;
error: string | null;
}
export const initialSendEgldState: SendEgldState = {
status: 'idle',
sessionId: null,
transactionHash: null,
error: null,
};
export type SendEgldOperation = (
input: SendEgldInput,
callbacks: SendEgldCallbacks,
) => Promise<SendEgldOutput>;
type TerminalState = {
status: 'done' | 'error';
sessionId: string;
error: string | null;
};
export class SendEgldLifecycle {
private state = initialSendEgldState;
private busy = false;
private output: SendEgldOutput | null = null;
private earlyTerminal: TerminalState | null = null;
public constructor(
private readonly onChange: (state: SendEgldState) => void,
) {}
public getState(): SendEgldState {
return this.state;
}
/**
* Starts one submission. A second call returns null synchronously at the
* first await boundary while signing or tracking is still active.
*/
public async send(
input: SendEgldInput,
operation: SendEgldOperation,
): Promise<SendEgldOutput | null> {
if (this.busy) {
return null;
}
this.busy = true;
this.output = null;
this.earlyTerminal = null;
this.transition({ ...initialSendEgldState, status: 'signing' });
const callbacks: SendEgldCallbacks = {
onSuccess: async (sessionId) => {
this.finish({
status: 'done',
sessionId,
error: null,
});
},
onFail: async (sessionId) => {
this.finish({
status: 'error',
sessionId,
error: 'Transaction failed on-chain.',
});
},
};
try {
const output = await operation(input, callbacks);
this.output = output;
const terminal = this.consumeEarlyTerminal(output.sessionId);
if (terminal) {
this.applyTerminal(terminal);
} else {
this.transition({
status: 'tracking',
sessionId: output.sessionId,
transactionHash: output.transactionHash,
error: null,
});
}
return output;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.busy = false;
this.transition({
status: 'error',
sessionId: null,
transactionHash: null,
error: message,
});
return null;
}
}
/**
* Reset is deliberately ignored while a submission is active. Clearing the
* visible state must never release the nonce guard before confirmation.
*/
public reset(): void {
if (!this.busy) {
this.transition(initialSendEgldState);
}
}
/** True while a submission holds the nonce guard. */
public get isBusy(): boolean {
return this.busy;
}
/**
* Release the guard when the tracked session can no longer reach a terminal
* state, which is what happens on logout: sdk-dapp resets its store, drops
* the session, and the success and failure callbacks registered for it can
* never fire again.
*
* The broadcast transaction may still be pending on the network, so the
* caller MUST refresh the account nonce from the network before submitting
* anything else. Releasing the guard without that refresh is exactly how the
* stale-nonce bug returns.
*/
public abandon(reason: string): void {
this.busy = false;
this.output = null;
this.earlyTerminal = null;
this.transition({
status: 'error',
sessionId: null,
transactionHash: null,
error: reason,
});
}
private finish(terminal: TerminalState): void {
if (!this.busy) {
return;
}
if (!this.output) {
this.earlyTerminal = terminal;
return;
}
if (terminal.sessionId === this.output.sessionId) {
this.applyTerminal(terminal);
}
}
private consumeEarlyTerminal(sessionId: string): TerminalState | null {
if (this.earlyTerminal?.sessionId !== sessionId) {
return null;
}
const terminal = this.earlyTerminal;
this.earlyTerminal = null;
return terminal;
}
private applyTerminal(terminal: TerminalState): void {
if (!this.output) {
return;
}
this.busy = false;
this.transition({
status: terminal.status,
sessionId: this.output.sessionId,
transactionHash: this.output.transactionHash,
error: terminal.error,
});
}
private transition(state: SendEgldState): void {
this.state = state;
this.onChange(state);
}
}
The React hook is now a thin adapter around that controller:
// lib/useSendEgld.ts — React adapter for the tested transaction lifecycle.
//
// The controller sets its busy flag before the first await. That closes the
// same-tick double-click race even before React has rendered disabled=true.
import { useCallback, useRef, useState } from 'react';
import { sendEgld, type SendEgldInput, type SendEgldOutput } from './transactions';
import {
initialSendEgldState,
SendEgldLifecycle,
type SendEgldState,
} from './sendEgldLifecycle';
export interface UseSendEgld {
state: SendEgldState;
send: (input: SendEgldInput) => Promise<SendEgldOutput | null>;
reset: () => void;
/** Release the nonce guard when the tracked session is gone (logout). */
abandon: (reason: string) => void;
}
export function useSendEgld(): UseSendEgld {
const [state, setState] = useState<SendEgldState>(initialSendEgldState);
const lifecycleRef = useRef<SendEgldLifecycle | null>(null);
if (!lifecycleRef.current) {
lifecycleRef.current = new SendEgldLifecycle(setState);
}
const send = useCallback(async (
input: SendEgldInput,
): Promise<SendEgldOutput | null> => {
return lifecycleRef.current?.send(input, sendEgld) ?? null;
}, []);
const reset = useCallback((): void => {
lifecycleRef.current?.reset();
}, []);
const abandon = useCallback((reason: string): void => {
lifecycleRef.current?.abandon(reason);
}, []);
return { state, send, reset, abandon };
}
The page
The form: receiver address, amount in EGLD (decimal, converted internally to the smallest denomination), submit button. It pulls login state and pending-transaction count from the sdk-dapp hooks, so the UI reflects the same store the manager writes to.
'use client';
// app/page.tsx — sign + send + track demo page.
//
// Three states:
// 1. Logged out → "Connect wallet" button (delegates to UnlockPanelManager).
// 2. Logged in, idle → form: receiver address + amount in EGLD.
// 3. Logged in, post-send → status display: hash, sessionId, success/error.
//
// The form's amount input is in EGLD (decimal), but lib/transactions.ts
// expects the smallest denomination (10^18). We do the conversion inline —
// see toSmallestDenomination() below — so the user can think in EGLD while
// the SDK still gets a precise bigint.
import { useState } from 'react';
import { useGetAccount } from '@multiversx/sdk-dapp/out/react/account/useGetAccount';
import { useGetIsLoggedIn } from '@multiversx/sdk-dapp/out/react/account/useGetIsLoggedIn';
import { useGetNetworkConfig } from '@multiversx/sdk-dapp/out/react/network/useGetNetworkConfig';
import { useGetPendingTransactions } from '@multiversx/sdk-dapp/out/react/transactions/useGetPendingTransactions';
import { UnlockPanelManager } from '@multiversx/sdk-dapp/out/managers/UnlockPanelManager/UnlockPanelManager';
import { getAccountProvider } from '@multiversx/sdk-dapp/out/providers/helpers/accountProvider';
import { useSendEgld } from '../lib/useSendEgld';
export default function SendPage(): JSX.Element {
const isLoggedIn = useGetIsLoggedIn();
const account = useGetAccount();
const { network } = useGetNetworkConfig();
const { state, send, reset, abandon } = useSendEgld();
// useGetPendingTransactions() returns SignedTransactionType[] — a flat
// array of currently-pending transactions, NOT a sessionId-keyed map (see
// node_modules/@multiversx/sdk-dapp/out/react/transactions/useGetPendingTransactions.d.ts).
// If you need session-keyed
// lookups instead, use useGetPendingTransactionsSessions(), which returns
// Record<string, SessionTransactionType>.
const pending = useGetPendingTransactions();
const pendingCount = pending.length;
const [receiver, setReceiver] = useState('');
const [amountEgld, setAmountEgld] = useState('0.001');
const isSubmissionPending =
state.status === 'signing' || state.status === 'tracking';
const handleConnect = (): void => {
// openUnlockPanel() returns Promise<void> (see
// node_modules/@multiversx/sdk-dapp/out/managers/UnlockPanelManager/UnlockPanelManager.d.ts).
// This handler is a sync onClick, so we explicitly discard the promise —
// matching the eslint no-floating-promises gate.
void UnlockPanelManager.getInstance().openUnlockPanel();
};
const handleLogout = async (): Promise<void> => {
// Logging out mid-submission drops the tracked session inside sdk-dapp, so
// the terminal callbacks can never fire and the nonce guard would stay
// held forever. Refuse while a submission is active; the button is
// disabled too, but a programmatic caller must not get past this either.
if (isSubmissionPending) {
return;
}
const provider = getAccountProvider();
await provider.logout();
// Nothing is in flight here, so releasing the guard is safe. If you add a
// force-disconnect path later, call abandon() and then refresh the account
// nonce from the network before allowing another send.
abandon('Disconnected.');
};
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
let amountInSmallest: bigint;
try {
amountInSmallest = toSmallestDenomination(amountEgld);
} catch (err) {
// eslint-disable-next-line no-console
console.error('Bad amount:', err);
return;
}
await send({
receiver,
amountInSmallestDenomination: amountInSmallest,
});
};
return (
<main
style={{
padding: '2rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
maxWidth: '720px',
margin: '0 auto',
}}
>
<h1>Sign and send a transaction</h1>
<p>
Network: <code>{network.chainId}</code>
</p>
{!isLoggedIn ? (
<button type="button" onClick={handleConnect} style={buttonStyle}>
Connect wallet
</button>
) : (
<>
<p>
Connected as <code>{account.address}</code>
</p>
<button
type="button"
onClick={() => {
void handleLogout();
}}
disabled={isSubmissionPending}
title={
isSubmissionPending
? 'Wait for the current transaction to settle before disconnecting.'
: undefined
}
style={{ ...buttonStyle, marginBottom: '1.5rem' }}
>
Disconnect
</button>
<form
onSubmit={(e) => {
void handleSubmit(e);
}}
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
>
<label>
Receiver (bech32 address):
<input
type="text"
required
value={receiver}
onChange={(e) => setReceiver(e.target.value)}
placeholder="erd1..."
style={inputStyle}
/>
</label>
<label>
Amount (EGLD):
<input
type="text"
required
inputMode="decimal"
value={amountEgld}
onChange={(e) => setAmountEgld(e.target.value)}
style={inputStyle}
/>
</label>
<button
type="submit"
disabled={isSubmissionPending}
style={buttonStyle}
>
{state.status === 'signing'
? 'Waiting for signature…'
: state.status === 'tracking'
? 'Tracking confirmation…'
: 'Send'}
</button>
</form>
<Result state={state} reset={reset} pending={pendingCount} />
</>
)}
</main>
);
}
interface ResultProps {
state: ReturnType<typeof useSendEgld>['state'];
reset: () => void;
pending: number;
}
function Result({ state, reset, pending }: ResultProps): JSX.Element | null {
if (state.status === 'idle') {
return null;
}
return (
<section style={{ marginTop: '1.5rem' }}>
<h2>Result</h2>
<p>
Status: <code>{state.status}</code> · Pending in store:{' '}
<code>{pending}</code>
</p>
{state.transactionHash && (
<p>
Hash: <code>{state.transactionHash}</code>
</p>
)}
{state.sessionId && (
<p>
Session ID: <code>{state.sessionId}</code>
</p>
)}
{state.error && (
<p style={{ color: 'crimson' }}>
Error: <code>{state.error}</code>
</p>
)}
<button
type="button"
onClick={reset}
disabled={state.status === 'signing' || state.status === 'tracking'}
style={buttonStyle}
>
Reset
</button>
</section>
);
}
/**
* Convert a decimal EGLD string to the smallest denomination (10^18).
*
* Avoid `parseFloat` + `* 1e18` — it loses precision for any meaningful
* balance. We do the multiplication on the integer part with bigint, then
* pad the fractional part with zeros to 18 digits.
*/
function toSmallestDenomination(decimalEgld: string): bigint {
const trimmed = decimalEgld.trim();
if (trimmed === '' || !/^\d+(\.\d+)?$/.test(trimmed)) {
throw new Error(`Invalid amount: "${decimalEgld}"`);
}
const dotIndex = trimmed.indexOf('.');
const integerPart = dotIndex === -1 ? trimmed : trimmed.slice(0, dotIndex);
const rawFraction = dotIndex === -1 ? '' : trimmed.slice(dotIndex + 1);
if (rawFraction.length > 18) {
throw new Error('Amount has more than 18 decimal places.');
}
const paddedFraction = rawFraction.padEnd(18, '0');
return BigInt(integerPart) * 10n ** 18n + BigInt(paddedFraction || '0');
}
const buttonStyle: React.CSSProperties = {
padding: '0.6rem 1.1rem',
fontSize: '1rem',
cursor: 'pointer',
};
const inputStyle: React.CSSProperties = {
display: 'block',
width: '100%',
marginTop: '0.25rem',
padding: '0.5rem',
fontSize: '0.95rem',
fontFamily: 'monospace',
};
How it works
Why signing is on the provider, not a hook. v4 had useSignTransactions(), a
hook that paid the cost of being a hook (Rules of Hooks, render-phase identity)
without using any of the benefits. The signing call is fundamentally imperative:
"user clicks Send, wallet pops up". Putting it on the provider makes the call site
honest about that.
Why sending is via TransactionManager, not the provider. Sending is a separate
concern from signing. TransactionManager handles batching, gas-bumping retries,
and integrates with the WebSocket-or-polling tracker. The provider is just a
signature surface; the manager is the orchestrator.
What sessionId is for. useGetPendingTransactions(),
useGetSuccessfulTransactions(), and useGetFailedTransactions() take no
arguments. Each returns the flat SignedTransactionType[] for every
transaction in that state, not just yours:
const pending = useGetPendingTransactions(); // every pending tx, all sessions
const successful = useGetSuccessfulTransactions();
const failed = useGetFailedTransactions();
To look up one session (the one sessionId from send() refers to), use the
...Sessions() variants instead. They return
Record<sessionId, SessionTransactionType>:
const sessions = useGetPendingTransactionsSessions();
const mySession = sessions[sessionId]; // SessionTransactionType | undefined
Pitfalls
getAccount() returns the network's last-known nonce. If you fire two
transactions in a session, the second is rejected unless you increment the nonce
locally:
const nonce = BigInt(getAccount().nonce);
// First transaction with nonce, signed and sent.
// For the second:
const tx2 = new Transaction({ ...rest, nonce: nonce + 1n });
The store updates the nonce after a confirmed transaction, but if you fire two before the first confirms you must manage the nonce yourself.
value: 1n means 0.000000000000000001 EGLD, not 1 EGLD. The recipe's UI takes
a decimal string and converts via toSmallestDenomination(). Avoid
parseFloat * 1e18, JavaScript loses precision past 2^53. Use bigint arithmetic.
The default GAS_LIMIT (50,000) covers an empty-data transfer. Each byte of
data adds 1500 gas. If you hand-roll a Transaction and skip the math, the
network rejects with "insufficient gas". The recipe's computeGasLimit() does
this, copy it.
The signed transaction's chainID must match the network you are sending to.
Pulling from getNetworkConfig().network.chainId ensures consistency. Never
hard-code "D", "T", or "1"; it makes the recipe environment-bound and
silently breaks when someone switches via a config change.
tx.sender MUST equal the logged-in account's bech32 address. The wallet refuses
to sign otherwise (it would sign with a different key than the tx's sender field,
then the network rejects on mismatch).
You might assume useGetPendingTransactions() accepts the sessionId
from send() and filters to just that transaction. It does not; the signature is
(): SignedTransactionType[], no parameters, verified against
node_modules/@multiversx/sdk-dapp/out/react/transactions/useGetPendingTransactions.d.ts
on @multiversx/sdk-dapp v5. It returns every pending transaction across
every session. For a single session's status, index into
useGetPendingTransactionsSessions() by sessionId instead.
See also
- Minimal sdk-dapp v5 in Next.js is the wallet-connect setup this recipe builds on.
- Minimal sdk-dapp v5 in Vite is the same pattern, Vite path.
- Migration: useSendTransactions to TransactionManager.send extends this single-transaction flow to grouped/batch sends.
- Read the connected account is how to read the account this recipe signs for.