Skip to main content

Best Practices

Recommended patterns for MultiversX applications.

Wallet Security

Never Hardcode Keys

// DON'T DO THIS
final mnemonic = 'abandon abandon abandon...';

// DO THIS
final mnemonic = Platform.environment['MVX_MNEMONIC'];
// Or load from secure storage
final mnemonic = await SecureStorage.read('wallet_mnemonic');

Clear Sensitive Data

class SecureAccount {
late final Account _account;
bool _disposed = false;

Future<void> init(String mnemonic) async {
_account = await Account.fromMnemonic(mnemonic);
// Clear mnemonic from memory after use
}

void dispose() {
_disposed = true;
// In production, zero out memory
}

void _checkDisposed() {
if (_disposed) {
throw StateError('Account has been disposed');
}
}

Future<Transaction> sign(Transaction tx) async {
_checkDisposed();
final signature = await _account.signTransaction(tx);
return tx.copyWith(newSignature: Signature.fromUint8List(signature));
}
}

Keep Signing Behind an Interface

Anything that can produce a signature over tx.serializeForSigning() can stand in for a local key: a hardware device, a remote signing service, a mobile secure enclave. IAccount is that seam -- implement it and every controller keeps working unchanged.

/// Signs on an external device instead of holding the key in this process.
class RemoteSigningAccount implements IAccount {
RemoteSigningAccount(this.address, this._client);


final Address address;
final ExternalSigningClient _client;

/// True when the device can only display short payloads and must sign the
/// Keccak-256 hash of the signing JSON instead of the JSON itself.

bool get prefersHashSigning => true;


Future<Uint8List> sign(Uint8List data) => _client.sign(data);


Future<Uint8List> signTransaction(Transaction transaction) =>
_client.sign(transaction.serializeForSigning());


Future<List<Uint8List>> signTransactions(List<Transaction> transactions) async {
final List<Uint8List> signatures = <Uint8List>[];
for (final Transaction transaction in transactions) {
signatures.add(await signTransaction(transaction));
}
return signatures;
}

// Guardian and relayer signatures cover exactly the same bytes as the
// sender's, so both delegate to signTransaction.

Future<Uint8List> signAsGuardian(Transaction transaction) =>
signTransaction(transaction);


Future<Uint8List> signAsRelayer(Transaction transaction) =>
signTransaction(transaction);


Future<Uint8List> signMessage(Message message) =>
_client.sign(const MessageComputer().computeBytesForSigning(message));


Future<bool> verifyTransactionSignature(
Transaction transaction,
Uint8List signature,
) async {
return UserVerifier.fromAddress(address)
.verify(transaction.serializeForSigning(), signature);
}


Future<bool> verifyMessageSignature(
Message message,
Uint8List signature,
) async {
return UserVerifier.fromAddress(address).verify(
const MessageComputer().computeBytesForSigning(message),
signature,
);
}
}

prefersHashSigning matters for devices with a small display: when it is true, apply TransactionComputer.applyOptionsForHashSigning(tx) before signing, or the chain rejects the result.

Transaction Safety

Always Verify Before Sending

class TransactionValidator {
static void validate(Transaction tx, AccountOnNetwork account) {
// Check nonce (manual validation)
if (tx.nonce != account.nonce) {
throw ValidationException(
'Nonce mismatch',
parameterName: 'nonce',
invalidValue: tx.nonce.value,
constraint: 'expected: ${account.nonce.value}',
);
}

// Check balance (manual validation)
final totalCost = tx.value.value +
tx.gasLimit.toBigInt * tx.gasPrice.toBigInt;

if (account.balance.value < totalCost) {
throw ValidationException(
'Insufficient balance',
parameterName: 'balance',
invalidValue: account.balance.value,
constraint: 'required: $totalCost',
);
}

// Check gas
if (tx.gasLimit < GasLimit(50000)) {
throw ValidationException(
'Gas limit too low',
parameterName: 'gasLimit',
invalidValue: tx.gasLimit.value,
constraint: 'must be >= 50000',
);
}

// Check receiver
if (tx.receiver.bech32.isEmpty) {
throw ValidationException(
'Receiver address is empty',
parameterName: 'receiver',
invalidValue: tx.receiver.bech32,
constraint: 'must not be empty',
);
}
}
}

Use Transaction Simulation

/// Simulate before sending to catch errors
Future<void> simulateTransaction(
GatewayNetworkProvider provider,
Transaction tx,
) async {
try {
// Use VM query to simulate
final result = await provider.simulateTransaction(tx);

if (!result.isSuccessful) {
// Check logs for error reason
String reason = 'Unknown failure';
if (result.logs != null) {
for (final event in result.logs!.events) {
if (event.identifier == 'signalError') {
reason = event.topics.toString();
}
}
}
throw GasEstimationException(
'Simulation failed: $reason',
transactionType: 'call', // 'call' | 'deploy' | 'upgrade'
);
}

print('Simulation passed!');
} on GasEstimationException catch (e) {
print('Simulation failed: ${e.message}');
print('Transaction may fail on-chain');
rethrow;
} on NetworkException catch (e) {
print('Network error during simulation: ${e.message}');
rethrow;
}
}

Nonce Management

Do not hand-roll a nonce counter -- the SDK ships NonceManager, which keeps a local counter ahead of the network, serialises concurrent callers, and can hand a reserved nonce back when a send falls through.

final nonces = NonceManager(
address: sender.address,
networkProvider: provider,
resyncInterval: const Duration(minutes: 5), // Duration.zero disables it
);

Future<String> sendOne(Transaction draft, UserSigner signer) async {
final nonce = await nonces.next(); // reserves it
try {
final signed = await draft.copyWith(newNonce: nonce).signWith(signer);
final hash = await provider.sendTransaction(signed);
nonces.applyNonce(nonce); // broadcast succeeded
return hash;
} catch (_) {
nonces.release(nonce); // give it back for reuse
rethrow;
}
}

resync() re-reads the account and moves the counter forward only, so a lagging network view can never rewind nonces you have already used.

Network Resilience

Multiple Providers

class ResilientProvider {
final List<GatewayNetworkProvider> _providers;
int _currentIndex = 0;

ResilientProvider(this._providers);

factory ResilientProvider.mainnet() => ResilientProvider([
GatewayNetworkProvider(
baseUrl: 'https://gateway.multiversx.com',
chainId: ChainId('1'),
),
GatewayNetworkProvider(
baseUrl: 'https://gateway-backup.example.com',
chainId: ChainId('1'),
),
]);

Future<T> execute<T>(
Future<T> Function(GatewayNetworkProvider) operation,
) async {
var lastError;

for (var i = 0; i < _providers.length; i++) {
final index = (_currentIndex + i) % _providers.length;

try {
final result = await operation(_providers[index]);
_currentIndex = index; // Remember successful provider
return result;
} catch (e) {
lastError = e;
print('Provider $index failed: $e');
}
}

throw lastError ?? StateError('All providers failed');
}
}

Rate Limiting

class RateLimitedProvider {
final GatewayNetworkProvider _provider;
final _queue = <_QueuedRequest>[];
final int _requestsPerSecond;
DateTime _lastRequest = DateTime.now();

RateLimitedProvider(
this._provider, {
int requestsPerSecond = 10,
}) : _requestsPerSecond = requestsPerSecond;

Future<T> execute<T>(Future<T> Function() operation) async {
final minInterval = Duration(
milliseconds: 1000 ~/ _requestsPerSecond,
);

final now = DateTime.now();
final elapsed = now.difference(_lastRequest);

if (elapsed < minInterval) {
await Future.delayed(minInterval - elapsed);
}

_lastRequest = DateTime.now();
return await operation();
}
}

Code Organization

Service Layer Pattern

/// Separate concerns into services
class WalletService {
final GatewayNetworkProvider _provider;

WalletService(this._provider);

Future<AccountOnNetwork> getAccountInfo(Address address) =>
_provider.getAccount(address);

Future<List<TokenOnNetwork>> getTokens(Address address) =>
_provider.getFungibleTokensOfAccount(address);
}

class TransactionService {
TransactionService(this._provider, IAccount account)
: _nonces = NonceManager(
address: account.address,
networkProvider: _provider,
),
_transfers = TransfersController(chainId: _provider.chainId);

final GatewayNetworkProvider _provider;
final NonceManager _nonces;
final TransfersController _transfers;

Future<String> sendEgld(
IAccount account,
Address recipient,
Balance amount,
) async {
final nonce = await _nonces.next();
final tx = await _transfers.createTransactionForNativeTransfer(
account,
nonce,
NativeTransferInput(receiver: recipient, amount: amount),
);
final hash = await _provider.sendTransaction(tx);
_nonces.applyNonce(nonce);
return hash;
}
}

class ContractService {
final SmartContractController _controller;

ContractService(this._controller);

Future<BigInt> getPrice(String token) async {
final result = await _controller.query(
endpointName: 'getPrice',
arguments: [token],
);
return infer<BigInt>(result.first);
}
}

Dependency Injection

/// Use DI for testability
class App {
final GatewayNetworkProvider provider;
final WalletService walletService;
final TransactionService txService;

App._({
required this.provider,
required this.walletService,
required this.txService,
});

factory App.production(IAccount account) {
final provider = GatewayNetworkProvider.mainnet();
return App._(
provider: provider,
walletService: WalletService(provider),
txService: TransactionService(provider, account),
);
}

factory App.development(IAccount account) {
final provider = GatewayNetworkProvider.devnet();
return App._(
provider: provider,
walletService: WalletService(provider),
txService: TransactionService(provider, account),
);
}

factory App.test(GatewayNetworkProvider mockProvider, IAccount account) {
return App._(
provider: mockProvider,
walletService: WalletService(mockProvider),
txService: TransactionService(mockProvider, account),
);
}
}

Gas Optimization

Batch Operations

A multi-transfer bundles several tokens to one receiver in a single transaction -- it does not fan out to many recipients. Use it when you would otherwise send the same receiver two or three transfers back to back:

// Inefficient: one transaction per token, three nonces, three fees
await sendToken(account, recipient, wegld);
await sendToken(account, recipient, usdc);
await sendToken(account, recipient, nft);

// Efficient: one MultiESDTNFTTransfer
final tx = await transfersController.createTransactionForTokenTransfer(
account,
nonce,
TokenTransferInput(
receiver: recipient,
transfers: <TokenTransfer>[
TokenTransfer.fungible(
tokenIdentifier: 'WEGLD-bd4d79',
amount: BigInt.parse('500000000000000000'),
),
TokenTransfer.fungible(
tokenIdentifier: 'USDC-c76f1f',
amount: BigInt.from(500000000),
),
TokenTransfer.nonFungible(
tokenIdentifier: 'MYNFT-abc123',
nonce: 42,
amount: BigInt.one,
),
],
),
);

Different recipients still need one transaction each -- send them concurrently with BatchHelper and consecutive nonces rather than serially.

Cache Network Config

class CachedNetworkConfig {
final GatewayNetworkProvider _provider;
NetworkConfig? _config;
DateTime? _fetchedAt;
final Duration _ttl;

CachedNetworkConfig(
this._provider, {
Duration ttl = const Duration(minutes: 5),
}) : _ttl = ttl;

Future<NetworkConfig> get() async {
if (_config != null &&
_fetchedAt != null &&
DateTime.now().difference(_fetchedAt!) < _ttl) {
return _config!;
}

_config = await _provider.getNetworkConfig();
_fetchedAt = DateTime.now();
return _config!;
}
}

Testing

Mock Providers

Implement the NetworkProvider interface, not a concrete provider class. noSuchMethod supplies forwarders for the members your test does not care about, so you only write the ones it exercises:

class MockNetworkProvider implements NetworkProvider {
final Map<String, AccountOnNetwork> _accounts = <String, AccountOnNetwork>{};
final Map<String, String> _transactionHashes = <String, String>{};

void addAccount(Address address, BigInt balance, int nonce) {
_accounts[address.bech32] = AccountOnNetwork(
address: address,
balance: Balance(balance),
nonce: Nonce(nonce),
);
}


Future<AccountOnNetwork> getAccount(Address address) async {
return _accounts[address.bech32] ??
AccountOnNetwork(
address: address,
balance: Balance.zero(),
nonce: const Nonce(0),
);
}


Future<String> sendTransaction(Transaction tx) async {
final hash = 'mock_${DateTime.now().millisecondsSinceEpoch}';
_transactionHashes[hash] = 'success';
return hash;
}

// Everything else throws NoSuchMethodError if a test touches it.

dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

Build canned network responses with TransactionOnNetwork.fromApiResponse(...):

final mockTx = TransactionOnNetwork.fromApiResponse(<String, dynamic>{
'txHash': 'abc123',
'status': 'success',
'sender': senderAddress.bech32,
'receiver': receiverAddress.bech32,
'value': '1000000000000000000',
'nonce': 5,
'gasLimit': 50000,
'gasPrice': 1000000000,
'chainID': 'D',
});

Logging

The SDK takes a Logger on providers and controllers; ConsoleLogger is the built-in implementation. Keep secrets out of the context maps you hand it:

final logger = ConsoleLogger(
minLevel: LogLevel.warning, // debug during development
includeTimestamp: true,
);

final provider = GatewayNetworkProvider.devnet(logger: logger);

/// Strips key material before anything is logged.
Map<String, dynamic> sanitize(Map<String, dynamic> data) {
return Map<String, dynamic>.from(data)
..remove('privateKey')
..remove('mnemonic')
..remove('password');
}

logger.info('Sending transaction', context: sanitize(<String, dynamic>{
'sender': account.address.bech32,
'nonce': nonce.value,
}));

Use NullLogger() to switch logging off entirely without changing call sites.

Checklist

Before Production

  • Remove all hardcoded keys/mnemonics
  • Enable error tracking (Sentry, etc.)
  • Set up monitoring for failed transactions
  • Implement rate limiting
  • Test with real tokens on devnet
  • Review gas settings
  • Add transaction simulation
  • Set up backup providers

Security Audit

  • No secrets in code or logs
  • Input validation on all user data
  • Proper error handling (no leaking info)
  • Dependencies are up to date
  • Using latest SDK version

Next Steps