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));
}
}

Use Hardware Wallets for Production

// For high-value operations, integrate with Ledger
class LedgerSigner implements TransactionSigner {

Future<String> sign(Transaction tx) async {
// Send to Ledger for signing
// User confirms on device
throw UnimplementedError('Use @ledgerhq/hw-transport-webusb');
}
}

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',
);
}

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

/// Thread-safe nonce manager
class NonceManager {
final GatewayNetworkProvider _provider;
final Map<String, int> _localNonces = {};

NonceManager(this._provider);

Future<Nonce> getNextNonce(Address address) async {
final key = address.bech32;

// Get fresh nonce from network
final account = await _provider.getAccount(address);
final networkNonce = account.nonce.value;

// Use higher of network or local
final localNonce = _localNonces[key] ?? 0;
final nextNonce = networkNonce > localNonce ? networkNonce : localNonce;

// Increment local tracker
_localNonces[key] = nextNonce + 1;

return Nonce(nextNonce);
}

void reset(Address address) {
_localNonces.remove(address.bech32);
}
}

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 {
final GatewayNetworkProvider _provider;
final NonceManager _nonceManager;

TransactionService(this._provider)
: _nonceManager = NonceManager(_provider);

Future<String> sendEgld(
Wallet wallet,
Address recipient,
BigInt amount,
) async {
// Implementation
}
}

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() {
final provider = GatewayNetworkProvider.mainnet();
return App._(
provider: provider,
walletService: WalletService(provider),
txService: TransactionService(provider),
);
}

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

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

Gas Optimization

Batch Operations

// Inefficient: Multiple transactions
for (final recipient in recipients) {
await sendEgld(wallet, recipient, amount);
}

// Efficient: Multi-transfer when possible
final transfers = recipients.map((r) =>
TokenTransfer.fungible(
tokenIdentifier: 'WEGLD-bd4d79',
amount: amount,
)
).toList();

// Use TransfersController or TransferTransactionsFactory
await controller.createTransactionForTokenTransfer(
account,
nonce,
TokenTransferInput(receiver: mainRecipient, transfers: transfers),
);

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

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

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: Nonce(0));
}


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

// For testing, use TransactionOnNetwork.fromApiResponse to create mock responses:
// final mockTx = TransactionOnNetwork.fromApiResponse({
// 'txHash': hash,
// 'status': 'success',
// 'sender': senderAddress.bech32,
// 'receiver': receiverAddress.bech32,
// 'value': '1000000000000000000',
// 'nonce': 5,
// 'gasLimit': 50000,
// 'gasPrice': 1000000000,
// 'chainID': 'D',
// });
}

Logging

class Logger {
static void transaction(String action, Map<String, dynamic> data) {
final sanitized = Map.from(data)
..remove('privateKey')
..remove('mnemonic');

print('[TX] $action: $sanitized');
}

static void error(String context, Object error, [StackTrace? stack]) {
print('[ERROR] $context: $error');
if (stack != null) {
print(stack);
}
}

static void network(String method, String url, int statusCode) {
print('[NET] $method $url -> $statusCode');
}
}

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