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:
| Type | Use Case |
|---|---|
GatewayNetworkProvider | Transaction submission, real-time data |
ApiNetworkProvider | Historical 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 tokens10^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:
- Encodes endpoint name and arguments
- Builds the data payload
- 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):
| Status | Meaning | Helper |
|---|---|---|
pending, received | In the mempool | status.isPending |
success, successful, executed | Executed successfully | tx.isSuccessful |
fail, failed, unsuccessful, invalid | Rejected or reverted | tx.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?
Accountcarries the whole wallet (address, keys, transaction building)UserSigneronly signs -- which is all a relayer service ever needs- The signing extensions (
signWith,signAsRelayer,signAsGuardian) all take aUserSigner
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.
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
| Party | Responsibility |
|---|---|
| User | Signs the action, owns the assets |
| Relayer | Pays the fee, provides the UX |
| Protocol | Verifies 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:
identifiers- Event names (swap, transfer, etc.)contractAddress- Specific contract- 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();
}