onchainlabs_flutter

Polygon EVM wallet helper for Flutter mobile apps.

  • create a Polygon-compatible wallet (address + private key + mnemonic)
  • authenticate against the OnchainLabs API with a signed sign-in challenge
  • read and move OROCASH tokens, including gasless transfers via EIP-7702
  • read contract state, fees, limits, roles and NFT membership

The wallet is an ordinary Ethereum wallet, so the address works on Polygon.


Security posture

Read this before shipping. It is short and it matters.

Key material lives on the device. The private key is held in FlutterSecureStorage with platform defaults. It is not hardware-backed, not bound to user presence, and not excluded from OS backups. If you are holding value on it, add those controls at the app level.

Never log key material. The SDK writes nothing to your log stream by default — see Diagnostics. Do not undo that by printing wallets, mnemonics or private keys yourself. A recovery phrase cannot be rotated.

Transaction signing has known limitations in 4.x. The signed payload does not carry a chain ID or verifying contract, and the batch encoding is not injective. If you operate on more than one chain, or rely on the SDK's batch path for high-value transfers, understand those limits first. See Known limitations.

Use certificate pinning. The SDK has none. Prefer OS-level pinning (NSPinnedDomains on iOS, network_security_config.xml on Android) so it covers every client in your app.


Install

dependencies:
  onchainlabs_flutter: ^5.0.0
import 'package:onchainlabs_flutter/onchainlabs_flutter.dart';

Requires Dart >=3.6.0 <4.0.0 and Flutter >=3.10.0.


Environments

Environment Base URL
Production https://api-ga.onchainlabs.ch
Development https://ga-api-dev.onchainlabs.ch

API keys are environment-specific. A development key returns 401 The provided API key is invalid. against production, and the reverse. Pair each key with its matching host.

SimpleOnchainApi defaults to the development host. Pass baseUrl explicitly in production rather than relying on the default:

final api = SimpleOnchainApi(
  publicKey: publicKey,
  baseUrl: 'https://api-ga.onchainlabs.ch',
);

WalletManager has no default — the base URL is a required argument.


Quick start

import 'package:onchainlabs_flutter/onchainlabs_flutter.dart';

final walletManager = await WalletManager.createAmoy(
  'https://ga-api-dev.onchainlabs.ch',
);

// Runs the operation with the key, then zeroes the buffer.
final balance = await walletManager.withPrivateKey(
  (key) => walletManager.executor.getOroCashBalanceFromWallet(key),
);

print('Balance: ${walletManager.executor.formatAmount(balance)}');

Wallets

1. Create a wallet

import 'package:onchainlabs_flutter/onchainlabs_flutter.dart';
import 'package:bip39_plus/bip39_plus.dart' as bip39;
import 'package:bip32_plus/bip32_plus.dart' as bip32;

Future<String> createWallet(WalletManager walletManager) async {
  final mnemonic = bip39.generateMnemonic();
  final seed = bip39.mnemonicToSeed(mnemonic);
  final root = bip32.BIP32.fromSeed(seed);
  final child = root.derivePath("m/44'/60'/0'/0/0");
  final privateKeyBytes = child.privateKey!;

  final address = walletManager.executor.getAddressFromPrivateKey(privateKeyBytes);

  await walletManager.savePrivateKey(privateKeyBytes);
  await walletManager.saveAddress(address);

  // Show the mnemonic to the user once, require confirmation, then discard it.
  // Do NOT log it and do NOT persist it alongside the key.
  return mnemonic;
}

The mnemonic is returned to you and not persisted by the library. Storing it is your decision; if you do, put it behind the same protection as the key.

2. Restore from a mnemonic

Future<void> restoreFromMnemonic(
  WalletManager walletManager,
  String userMnemonic,
) async {
  final seed = bip39.mnemonicToSeed(userMnemonic);
  final root = bip32.BIP32.fromSeed(seed);
  final child = root.derivePath("m/44'/60'/0'/0/0");
  final privateKeyBytes = child.privateKey!;

  final address = walletManager.executor.getAddressFromPrivateKey(privateKeyBytes);

  await walletManager.savePrivateKey(privateKeyBytes);
  await walletManager.saveAddress(address);
}

3. Restore from a private key

import 'dart:typed_data';
import 'package:hex/hex.dart';

Future<void> restoreFromPrivateKey(
  WalletManager walletManager,
  String userPrivateKeyHex, // 64 hex chars, no 0x
) async {
  final privateKeyBytes = Uint8List.fromList(HEX.decode(userPrivateKeyHex));
  final address = walletManager.executor.getAddressFromPrivateKey(privateKeyBytes);

  await walletManager.savePrivateKey(privateKeyBytes);
  await walletManager.saveAddress(address);
}

4. Register the wallet

Future<void> register(WalletManager walletManager) async {
  final result = await walletManager.withPrivateKey(
    (key) => walletManager.executor.registerWallet(key),
  );

  if (!result.success) {
    print('Registration failed: ${result.error}');
  }
}

Registration authenticates with the wallet's own signature and needs no API key beyond the public one. Whitelisting is a privileged operation and belongs behind an authenticated backend endpoint — see Removed in 5.0.0.


Key handling

withPrivateKey reads the key, runs your operation, and zeroes the buffer afterwards — including if the operation throws. Prefer it over getPrivateKey for one-off work.

final status = await walletManager.withPrivateKey(
  (key) => walletManager.executor.getDelegationStatus(key),
);

Do not retain the buffer past the callback: it will be zeros. If you hold your own copy, clear it yourself:

WalletManager.zeroise(myKeyBuffer);

This narrows exposure rather than eliminating it. Anything the key is passed to may copy it, and Dart strings cannot be zeroed at all.

Clear everything on logout:

await walletManager.deleteWallet(); // deletes the key and clears the auth cache

Amounts

Token amounts are integers in base units. Parse decimal input as a string, never through a double:

final executor = walletManager.executor;

final raw = executor.parseAmount('100.5');       // exact
final text = executor.formatAmount(raw);         // "100.5"

parseAmount throws FormatException on malformed input, or on more decimal places than the token has, rather than silently truncating the user's amount.

If you know the precision without an initialised executor:

final raw = Eip7702Executor.parseAmountWithDecimals('100.5', 6);

Deprecated: toRawAmount(double) is deprecated and will be removed in a future major. Binary floating point cannot represent most decimal fractions exactly, and above roughly 9×10¹⁵ base units it cannot represent the value at all. toHumanAmount returns a double and is for display only — never feed its result back into a transaction.


API access without a wallet

SimpleOnchainApi covers the endpoints that need only a public API key.

final api = SimpleOnchainApi(
  publicKey: publicKey,
  baseUrl: 'https://api-ga.onchainlabs.ch',
);

final res = await api.balanceOfPublic(walletAddress);
// {address: 0x..., balance: "1000000", success: true}

Write operations need a wallet to sign with:

final wallet = PolygonWallet(
  address: address,
  privateKeyHex: privateKeyHex,
  mnemonic: mnemonic,
);

final res = await api.mint(
  signerWallet: wallet,
  contractAddress: contractAddress,
  receiver: walletAddress,
  amountHuman: '1000',   // decimal string, converted exactly
  waitForTx: true,
);

transfer, transferFrom, buyToken, sellToken, approve and checkAccount follow the same shape.


EIP-7702 gasless transactions

Users do not need MATIC/POL to transact.

5. Initialise

// Polygon Amoy testnet
final walletManager = await WalletManager.createAmoy(baseUrl);

// Polygon mainnet
final walletManager = await WalletManager.createMainnet(baseUrl);

Contract addresses are compiled in, not fetched. /contracts is unauthenticated and unpinned, and its answer becomes the contract your wallet delegates its account to — so whoever answered that request would choose it. The addresses don't change, so there is nothing to gain by asking.

Chain Delegate Token
Polygon mainnet (137) 0x11a2C6C6…6BDD 0x4CD6FFD0…1Fad
Polygon Amoy (80002) 0xAC5d44B5…b291 0xcc7fA402…a8A2

Read what a build is pinned to via kOnchainLabsContracts. For a private deployment or a chain this release doesn't know, pass both explicitly:

final walletManager = await WalletManager.create(
  baseUrl: baseUrl,
  rpcUrl: rpcUrl,
  chainId: 1337,
  delegateAddress: '0x…',
  tokenAddress: '0x…',
);

A chain with neither compiled-in constants nor overrides throws ContractDiscoveryException rather than guessing:

try {
  final walletManager = await WalletManager.createAmoy(baseUrl);
} on ContractDiscoveryException catch (e) {
  // Unknown chain and no addresses supplied. Do not proceed.
}

6. Authorize (enable gasless)

final result = await walletManager.withPrivateKey(
  (key) => walletManager.executor.authorize(key, waitForTx: true),
);

final status = await walletManager.withPrivateKey(
  (key) => walletManager.executor.getDelegationStatus(key),
);

if (status.isKnown) {
  print('Delegated: ${status.isDelegated}');
} else {
  print('Could not determine delegation: ${status.error}');
}

isDelegated == false alone is ambiguous — it is also what a failed lookup returns. Check isKnown before treating it as authoritative.

7. Transfer

final executor = walletManager.executor;
final contractAddress = walletManager.orocashAddress!;

final result = await walletManager.withPrivateKey(
  (key) => executor.transferOroCash(
    key,
    contractAddress,
    '0xRecipientAddress',
    executor.parseAmount('100.5'),
    waitForTx: true,
  ),
);

if (result.success) {
  print('TX: ${result.txHash}');
}

8. Buy, sell, burn

await executor.buyToken(key, contractAddress, recipient, executor.parseAmount('50'));
await executor.sellToken(key, contractAddress, recipient, executor.parseAmount('25'));
await executor.disposeToken(key, contractAddress, executor.parseAmount('10'));

9. Approve

await executor.approve(
  key,
  contractAddress,
  '0xSpenderAddress',
  executor.parseAmount('1000'),
);

await executor.approveUnlimited(key, contractAddress, '0xSpenderAddress');

10. Batch

final builder = BatchCallBuilder()
  .addTransfer(
    contractAddress: contractAddress,
    to: '0xAddress1',
    amount: executor.parseAmount('100'),
  )
  .addTransfer(
    contractAddress: contractAddress,
    to: '0xAddress2',
    amount: executor.parseAmount('50'),
  );

final result = await executor.executeBatch(key, builder, waitForTx: true);

See Known limitations on batch encoding before using this for high-value transfers.


Reads

11. Token information

final name = await executor.getTokenName(key);
final symbol = await executor.getTokenSymbol(key);
final decimals = await executor.getTokenDecimals(key);
final totalSupply = await executor.getTotalSupply(key);

print('$name ($symbol), ${executor.formatAmount(totalSupply)}');

12. Contract state, fees and limits

final isPaused = await executor.isPaused(key);
final hasFee = await executor.hasFee(key);
final custodyEnabled = await executor.isCustodyEnabled(key);

// 100 bps = 1%
final percentFeeBps = await executor.getPercentFeeBps(key);
final fixedFee = await executor.getFixedFee(key);

final globalMin = await executor.getTxLimitGlobalMin(key);
final globalMax = await executor.getTxLimitGlobalMax(key);
final userLimits = await executor.getUserLimit(key, address);

13. Roles

final isAdmin = await executor.hasRole(key, 0, address);
final roles = await executor.getUserRoles(key, address);

for (final entry in roles.entries) {
  print('${Eip7702Executor.getRoleName(entry.key)}: ${entry.value}');
}
ID Role ID Role
0 Admin 3 Extractor
1 Moderator 4 CFO
2 Minter 5 Whitelist

14. NFT membership

final check = await executor.checkMembership(key, address);

if (!check.isKnown) {
  print('Could not verify membership: ${check.error}');
} else if (check.hasMembership) {
  final info = await executor.getWalletMembershipInfo(key);
  print('Token ID: ${info.tokenId}, minted ${info.formattedMintedAt}');
}

Prefer checkMembership over hasMembership anywhere the answer gates access: hasMembership returns false both for "no membership" and for "the check failed".

15. Everything at once

final allInfo = await executor.getAllContractInfo(key);

16. Wallet status

final result = await executor.getWalletStatus(key);

if (result.success) {
  final data = result.data!;
  print('Whitelisted: ${data['whitelisted']}');
  print('Delegated: ${data['delegated']}');
} else if (result.error?.contains('Wallet not found') == true) {
  print('Not registered — call registerWallet first');
}
Field Type Description
whitelisted bool Wallet has the Whitelist role and can transact
delegated bool EIP-7702 delegation is active (gasless enabled)
roles List Role IDs assigned to the wallet

Sign-in challenge validation

The SDK authenticates by signing an EIP-4361 (Sign-In with Ethereum) challenge from POST /random. Because authentication and transaction signing share the same primitive, a challenge is validated before it is signed: it must parse as a current, well-formed sign-in message addressed to this wallet, on the configured chain. Opaque payloads — a transaction digest, for instance — are rejected outright.

Rejection throws ChallengeRejected:

try {
  final headers = await executor.createAuthHeaders(key);
} on ChallengeRejected catch (e) {
  // The server did not return a challenge we are willing to sign.
  print(e.reason);
}

Domain binding is opt-in. Setting it is the stronger posture, but it will fail every login if the challenge's domain differs from the host you call, so confirm they agree in each environment first:

executor.expectedSiweDomain = 'api-ga.onchainlabs.ch';

SimpleOnchainApi takes the same settings at construction:

final api = SimpleOnchainApi(
  publicKey: publicKey,
  baseUrl: 'https://api-ga.onchainlabs.ch',
  chainId: 137,
  expectedSiweDomain: 'api-ga.onchainlabs.ch',
);

Diagnostics

The SDK writes nothing to your log stream unless you ask it to.

OnchainLabsLog.handler = (message) => debugPrint('[onchainlabs] $message');

Messages pass through a redactor that masks API keys, signatures, bearer tokens, private keys and BIP-39 phrases. Treat that as a backstop, not a licence: never pass secret material to a log call.

Disable again by setting handler to null.


Error handling

Type Raised when
ContractDiscoveryException Contract addresses could not be established at initialisation
ChallengeRejected The sign-in challenge was not something the SDK will sign
FormatException An amount was malformed or too precise for the token
ArgumentError An address was not 20 bytes of hex

API responses carry httpStatusCode, and set transportError: true when the body was not a JSON object at all — a proxy error page or a captive portal, rather than a genuine rejection by the API:

if (result['transportError'] == true) {
  // Do not treat this as an authoritative "no".
}

Removed in 5.0.0

5.0.0 removes two surfaces. Both were unused by every known integrator.

Removed Replace with
adminMint, adminWhitelist A backend endpoint. No key that confers minting or whitelisting authority can live safely in a mobile client.
registerAndWhitelist(key, secretApiKey) registerWallet(key), then have your backend whitelist in response to an authenticated user action
getGoldPrice, getBalanceWithUsdValue A server-issued quote carrying a signature you verify
GoldPrice, GoldPriceResult, the goldPrice* tuning fields
calculateTokenUsdValue, calculateTokenUsdValueFromRaw, formatUsdValue
getAllContractInfo no longer returns goldPrice or balanceUsdValue

OnchainLabsApi loses adminMint, adminWhitelist and getGoldPrice. If you implement that interface yourself, delete those three methods.

Nothing else changed. Everything carried over from 4.x keeps its signature.

Why

The SDK no longer has any method that requires a secret API key. That is the point of the change: an embedded key is public by construction, so the fix is not to hide it better but to remove the reason for a client to hold one at all. Registration and authentication use the wallet's own signature and the public key, which is an identifier rather than a credential.

The gold-price path went because it had no consumer — the application takes a EUR-per-gram price from its own backend, and a USD-per-milligram figure is not usable by a product priced in euros without an FX rate the SDK does not supply.


Known limitations

Carried from the 2026 security review; scheduled for 5.0.0.

  • No domain separation between signing contexts. Authentication and transaction signing use the same EIP-191 primitive. Challenge validation closes the practical path, but EIP-712 typed signing is the real fix.
  • Signed payloads omit chain ID and verifying contract. A signature is valid on any chain and against any deployment of the same scheme.
  • Batch encoding is not injective. Variable-length call data is concatenated without length prefixes, so distinct batches can produce the same digest.
  • No certificate pinning, and no way to inject your own http.Client.
  • No confirmation binding. Nothing ties what a user approves on screen to the bytes that get signed.
  • Secure storage uses platform defaults — not hardware-backed, no user-presence requirement, not excluded from OS backups.

License

MIT — see LICENSE.