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