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:
pending- In mempoolsuccess- Executed successfullyinvalid- Validation failedfail- Execution failed (reverted)
Relayed Transaction
Relayed transactions enable gas-free user experiences.
The Two Signer Pattern
// User: performs the action
final account = await Account.fromPem(pem);
// Relayer: pays for gas
final accountRelayer = UserSigner.fromPem(pemRelayer);
Why UserSigner for relayer?
Accountloads the full wallet (for transaction building)UserSigneris lighter (only for signing)- Relayer only needs to sign, not build transactions
Specifying the Relayer
options: BaseControllerInput(
gasLimit: GasLimit(25000000),
relayer: relayerAddress,
),
This embeds the relayer address in the transaction structure, enabling the protocol to:
- Charge gas to relayer's account
- Verify relayer signature
- Execute on behalf of user
Dual Signature Process
// Step 1: User signs (happens in controller.call)
final innerTx = await controller.call(...);
// Step 2: Relayer signs
final fullySignedTx = await innerTx.signAsRelayer(accountRelayer);
Signature verification:
- Inner transaction has user's signature
- Outer wrapper has relayer's signature
- Both must be valid for execution
Economic Model
| Party | Responsibility |
|---|---|
| User | Signs action, owns assets |
| Relayer | Pays gas, provides UX |
| Protocol | Verifies both signatures |
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:
static Balance fromEgld(double egld) {
final raw = BigInt.from(egld * 1e18);
return Balance(raw);
}
Transfer Input
NativeTransferInput(
receiver: bobAddress,
amount: Balance.fromEgld(0.1),
)
Other transfer inputs:
// ESDT transfer
EsdtTransferInput(
receiver: bobAddress,
tokenIdentifier: 'MEX-a659d0',
amount: BigInt.from(1000000),
)
// NFT transfer
NftTransferInput(
receiver: bobAddress,
tokenIdentifier: 'NFT-abc123',
nonce: 1,
)
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
// Example swap event
{
'identifier': 'swap',
'tokenIn': 'WEGLD-a28c59',
'tokenOut': 'MEX-a659d0',
'amountIn': BigInt.from(...),
'amountOut': BigInt.from(...),
'caller': 'erd1...',
}
Production Considerations
// Handle connection lifecycle
swapStream.events.listen(
onData: (event) => handleEvent(event),
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();
}