Skip to main content

Cookbook Breakdown

In-depth explanations of each cookbook example, covering every line of code.

Token Swap

The token swap example demonstrates swapping WEGLD for MEX on the xExchange DEX.

Step 1: Logger Configuration

final logger = ConsoleLogger(
minLevel: LogLevel.debug, // Show all log levels
includeTimestamp: true, // Add timestamps to logs
prettyPrintContext: true, // Format JSON nicely
showBorders: true, // Visual separators
useColors: true, // Colored output
);

Why this matters: During development, verbose logging helps trace transaction flow and debug issues. In production, set minLevel: LogLevel.warning.

Step 2: Wallet Loading

final pem = File('assets/alice.pem').readAsStringSync();
final account = await Account.fromPem(pem);

The Account object:

  • Contains the private key for signing
  • Provides the public address
  • Never transmits the private key over the network

PEM format:

-----BEGIN PRIVATE KEY for erd1...-----
<base64 encoded key>
-----END PRIVATE KEY for erd1...-----

Step 3: Network Provider

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

Provider types:

TypeUse Case
GatewayNetworkProviderTransaction submission, real-time data
ApiNetworkProviderHistorical data, token queries, indexing

Both work for this example, but ApiNetworkProvider has better token query support.

Step 4: Fresh Account State

final freshAccount = await provider.getAccount(aliceAddress);
final currentNonce = freshAccount.nonce;

Critical: Always fetch fresh nonce before transactions. Stale nonce = failed transaction.

What getAccount returns:

class AccountOnNetwork {
final Address address;
final Balance balance; // EGLD balance
final Nonce nonce; // Transaction counter
// ... more fields
}

Step 5: ABI Loading

final abiJson = File('assets/pair.abi.json').readAsStringSync();
final abi = SmartContractAbi.fromJson(abiJson);

The ABI contains:

  • Endpoint definitions (functions you can call)
  • Event definitions (events the contract emits)
  • Type definitions (structs, enums used by the contract)

Inspecting the ABI:

// List all endpoints
for (final endpoint in abi.endpoints) {
print('${endpoint.name}: ${endpoint.inputs.length} inputs');
}

// Check for specific endpoint
final hasSwap = abi.endpoints.any((e) => e.name == 'swapTokensFixedInput');

Step 6: Controller Setup

final controller = SmartContractController(
contractAddress: SmartContractAddress.fromBech32('erd1qqq...'),
abi: abi,
networkProvider: provider,
logger: logger,
);

The controller:

  • Encodes arguments using ABI type information
  • Builds transactions with correct data payload
  • Decodes query results automatically

Step 7: Token Definitions

final wegldAmount = BigInt.from(1) * BigInt.from(10).pow(17);
final wegldToken = TokenIdentifierValue('WEGLD-a28c59');
final mexToken = TokenIdentifierValue('MEX-a659d0');

Understanding amounts:

  • EGLD has 18 decimals
  • 10^17 = 0.1 tokens
  • 10^18 = 1.0 token
  • Always work with raw BigInt values

Token identifier format: {TICKER}-{HEX_SUFFIX}

Step 8: Query Expected Output

final amountOutResult = await controller.query(
endpointName: 'getAmountOut',
arguments: [wegldToken, wegldAmount],
);
final amountOut = infer<BigInt>(amountOutResult[0]);

Queries vs Transactions:

  • Queries are free (no gas)
  • Queries are read-only
  • Queries execute instantly
  • Results are automatically decoded

Step 9: Slippage Calculation

final minAmountOut = (amountOut * BigInt.from(9900)) ~/ BigInt.from(10000);

Why slippage matters:

  • Prices change between query and execution
  • Other transactions may front-run yours
  • Without minimum, you could get 0 tokens

Common slippage values:

  • 0.5% = multiply by 9950, divide by 10000
  • 1% = multiply by 9900, divide by 10000
  • 3% = multiply by 9700, divide by 10000

Step 10: Token Transfer Attachment

final tokenTransfer = TokenTransferValue.fromPrimitives(
tokenIdentifier: wegldToken.identifier,
amount: wegldAmount,
);

Multi-transfer capability:

tokenTransfers: [
tokenTransfer1,
tokenTransfer2,
// Can attach multiple tokens
],

Step 11: Transaction Building

final tx = await controller.call(
account: account,
nonce: currentNonce,
endpointName: 'swapTokensFixedInput',
arguments: [mexToken, minAmountOut],
tokenTransfers: [tokenTransfer],
options: BaseControllerInput(gasLimit: GasLimit(25000000)),
);

What controller.call does:

  1. Encodes endpoint name and arguments
  2. Builds the data payload
  3. Signs the transaction with sender's key

Step 12: Transaction Submission

final txHash = await provider.sendTransaction(tx);

The transaction hash:

  • 64-character hex string
  • Unique identifier for tracking
  • Use in explorer: https://devnet-explorer.multiversx.com/transactions/{hash}

Step 13: Awaiting Completion

final watcher = TransactionWatcher(networkProvider: provider);
final result = await watcher.awaitCompleted(txHash);

Transaction states (result.status.status is the raw string; the helpers below classify it):

StatusMeaningHelper
pending, receivedIn the mempoolstatus.isPending
success, successful, executedExecuted successfullytx.isSuccessful
fail, failed, unsuccessful, invalidRejected or revertedtx.hasFailed

watcher.awaitCompleted(hash) returns only once the status is final.


Relayed Transaction

Relayed transactions enable gas-free user experiences.

The Two Signer Pattern

// User: performs the action
final account = await Account.fromPem(pem);
final userSigner = UserSigner.fromSecretKey(account.secretKey);

// Relayer: pays for gas
final accountRelayer = UserSigner.fromPem(pemRelayer);
final relayerAddress = await accountRelayer.getAddress();

Why UserSigner for the relayer?

  • Account carries the whole wallet (address, keys, transaction building)
  • UserSigner only signs -- which is all a relayer service ever needs
  • The signing extensions (signWith, signAsRelayer, signAsGuardian) all take a UserSigner

Specifying the Relayer

The relayer must be on the transaction before anyone signs -- the address is part of the signed payload -- and the transaction must be at version 2. RelayedTransactionsFactory.applyRelayer does both, on a still-unsigned transaction:

final callFactory = SmartContractCallFactory(
contractAddress: contractAddress,
abi: abi,
chainId: provider.chainId,
);
final unsigned = callFactory.createCall(
sender: aliceAddress,
nonce: currentNonce,
endpointName: 'swapTokensFixedInput',
arguments: <dynamic>[mexToken, minAmountOut],
tokenTransfers: <TokenTransferValue>[tokenTransfer],
gasLimit: const GasLimit(25000000),
);

final relayedFactory = RelayedTransactionsFactory(
const RelayedTransactionsConfig(chainId: ChainId.devnet()),
);
final relayed = relayedFactory.applyRelayer(unsigned, relayerAddress);
// gasLimit is now 25,050,000: the endpoint cost plus the relayed base cost.
caution

BaseControllerInput also has a relayer field, but controller.call builds the call at version: 1 and signing a relayed transaction below version 2 fails. Use the factory flow above. See Relayed Transactions for the full story.

Dual Signature Process

// Step 1: the user signs
final userSignedTx = await relayed.signWith(userSigner);

// Step 2: the relayer co-signs the very same transaction
final fullySignedTx = await userSignedTx.signAsRelayer(accountRelayer);

What actually travels: one flat transaction carrying relayer and relayerSignature alongside sender and signature. There is no outer wrapper and no inner-transaction bundle.

Signature verification:

  • The signing payload excludes all three signature fields, so user and relayer sign identical bytes
  • Order does not matter -- relayer first is just as valid
  • Both signatures must verify, and sender and relayer must live in the same shard

Economic Model

PartyResponsibility
UserSigns the action, owns the assets
RelayerPays the fee, provides the UX
ProtocolVerifies both signatures, charges the relayer

EGLD Transfer

The simplest transaction type - sending native EGLD.

TransfersController

final controller = TransfersController(chainId: const ChainId.devnet());

Difference from SmartContractController:

  • No ABI needed
  • Simpler interface
  • Optimized for transfers

Balance Creation

Balance.fromEgld(0.1)

Under the hood: Balance.fromEgld(num value) formats the value to 18 decimal places as a string, then concatenates the integer and fractional digits and parses the result as a BigInt. The conversion never goes through a lossy double * 1e18 multiplication.

For amounts that come from user input, prefer Balance.fromEgldString('0.1'): it skips the floating-point representation entirely.

Transfer Input

NativeTransferInput(
receiver: bobAddress,
amount: Balance.fromEgld(0.1),
)

Token transfers use a single input type -- one receiver, one or more TokenTransfer entries:

// ESDT transfer
TokenTransferInput(
receiver: bobAddress,
transfers: <TokenTransfer>[
TokenTransfer.fungible(
tokenIdentifier: 'MEX-a659d0',
amount: BigInt.from(1000000),
),
],
)

// NFT transfer (nonce identifies the instance)
TokenTransferInput(
receiver: bobAddress,
transfers: <TokenTransfer>[
TokenTransfer.nonFungible(
tokenIdentifier: 'MYNFT-abc123',
nonce: 1,
amount: BigInt.one,
),
],
)

Pass them to controller.createTransactionForTokenTransfer(account, nonce, input); several entries in one input become a single MultiESDTNFTTransfer.

Nonce Awaiting

final awaiter = AccountAwaiter(networkProvider: provider);
final newAccount = await awaiter.awaitNonceIncrement(
alice.address,
currentNonce,
options: const AccountAwaitingOptions(
timeout: Duration(minutes: 2),
pollingInterval: Duration(seconds: 5),
),
);

Why await nonce instead of transaction?

  • More reliable for chains of transactions
  • Provides updated account state
  • Useful for UX (show new balance immediately)

WebSocket Events

Real-time event streaming for live dApps.

Configuration

WebSocketEventStreamConfig.byIdentifiers(
websocketUrl: 'wss://kepler-api.projectx.mx/devnet/events',
identifiers: const ['swap'],
contractAddress: controller.contractAddress,
headers: {'Api-Key': 'your-api-key'},
abi: abi,
logger: logger,
)

Filter hierarchy:

  1. identifiers - Event names (swap, transfer, etc.)
  2. contractAddress - Specific contract
  3. ABI - Enable parsing

Event Stream

swapStream.events.listen((result) {
final parsed = result.parsedEvent!;
print(parsed.toMap());
});

Stream properties:

  • Continuous until disconnected
  • Automatic reconnection (configurable)
  • Back-pressure handling

Parsed Event Structure

parsed.toMap() returns Map<String, TypedValue> -- one entry per ABI-declared field, keyed by the field name from the event definition. The identifier is not in the map; read it from the event itself.

final ParsedEvent parsed = result.parsedEvent!;

print(parsed.event.identifier); // 'swap'

final Map<String, TypedValue> fields = parsed.toMap();
final tokenIn = fields['token_in']?.nativeValue; // 'WEGLD-a28c59'
final amountIn = fields['amount_in']?.nativeValue; // BigInt

// Or fetch one field directly
final caller = parsed.getValueByName('caller')?.nativeValue;

Field names come straight from the ABI's event definition, so they match the contract's own spelling.

Production Considerations

// Handle connection lifecycle (onData is the positional argument)
swapStream.events.listen(
(result) => handleEvent(result),
onError: (error) => reconnect(),
onDone: () => cleanup(),
cancelOnError: false, // Keep listening after errors
);

// Graceful shutdown
Future<void> shutdown() async {
await swapStream.disconnect();
}

Common Patterns

Error Recovery

Future<T> withRetry<T>(Future<T> Function() operation) async {
for (var i = 0; i < 3; i++) {
try {
return await operation();
} catch (e) {
if (i == 2) rethrow;
await Future.delayed(Duration(seconds: 1 << i));
}
}
throw StateError('Unreachable');
}

Balance Checking

Future<void> ensureSufficientBalance(
NetworkProvider provider,
Address address,
Balance required,
) async {
final account = await provider.getAccount(address);
if (account.balance < required) {
throw ValidationException(
'Insufficient balance',
parameterName: 'balance',
invalidValue: account.balance.value,
constraint: 'required: ${required.toDenominatedTrimmed}',
);
}
}

Transaction Batching

// Send multiple transactions with incrementing nonce
var nonce = freshAccount.nonce;
for (final transfer in transfers) {
final tx = await controller.call(
account: account,
nonce: nonce,
endpointName: 'transfer',
arguments: [transfer.recipient, transfer.amount],
options: BaseControllerInput(gasLimit: GasLimit(10000000)),
);
await provider.sendTransaction(tx);
nonce = nonce.increment();
}