Overview
Dynamic embedded wallets give you cryptographic key material for two curves:
- secp256k1 via the EVM wallet — used by Bitcoin, Ethereum, Tron, Cosmos, XRP, Starknet, and many others
- Ed25519 via the Solana wallet — used by Solana, NEAR, Cardano, Aptos, Mavryk, Algorand, and others
Any blockchain that uses one of these curves can be supported without creating new wallets or prompting the user to re-authenticate. This page explains how to wire up a new chain yourself.
Decision: which root wallet to use?
| Your chain uses | Root wallet | Signing method |
|---|
| secp256k1 | EVM wallet | DynamicSDK.instance.wallets.signRawMessage(...) |
| Ed25519 | Solana wallet | DynamicSDK.instance.solana.createSigner(wallet: ...).signMessage(...) |
If your chain uses a derived curve (e.g., Starknet derives its Stark key from secp256k1), start from the EVM wallet and apply the key-derivation step locally in Dart.
Step 1: Add Dart crypto dependencies
Choose the packages you need based on the chain’s address encoding and hash functions:
# pubspec.yaml
dependencies:
# Core cryptography (covers most chains)
pointycastle: ^3.9.1 # secp256k1, SHA-256, SHA-3, Keccak, RIPEMD-160
# Extended hashing
hashlib: ^1.19.2 # Blake2b (Cardano, Mavryk, NEAR transactions)
# Encoding
bs58: ^1.0.2 # Base58 / base58check (Bitcoin, Tron, XRP, Mavryk, Solana)
bech32: ^0.2.2 # Bech32 (Cosmos, Cardano)
convert: ^3.1.1 # Hex utilities
# Networking
http: ^1.2.2
Run flutter pub get after updating pubspec.yaml.
Step 2: Derive the address
secp256k1 chains
secp256k1 chains need the compressed 33-byte public key. You recover it once per session by signing a deterministic message and running ecrecover:
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';
import 'package:pointycastle/ecc/curves/secp256k1.dart';
import 'package:pointycastle/ecc/ecc_fp.dart' as fp;
// Cache this per session — recovery only needs to happen once
Uint8List? _cachedPubkey;
Future<Uint8List> getEvmCompressedPubkey() async {
if (_cachedPubkey != null) return _cachedPubkey!;
const recoveryMessage = 'MY_CHAIN_PUBKEY_RECOVERY';
final evmWallet = DynamicSDK.instance.wallets.userWallets
.firstWhere((w) => w.chain == 'EVM');
// Sign EIP-191 prefixed message to get a recoverable signature
final signature = await DynamicSDK.instance.wallets.signMessage(
walletId: evmWallet.id,
accountAddress: evmWallet.address,
message: recoveryMessage,
);
// Reconstruct the hash that was actually signed
final prefix = '\x19Ethereum Signed Message:\n${recoveryMessage.length}';
final combined = Uint8List.fromList([...prefix.codeUnits, ...recoveryMessage.codeUnits]);
final msgHash = Digest('Keccak/256').process(combined);
// ecrecover → compressed pubkey
final sigBytes = hexToBytes(strip0x(signature));
var v = sigBytes[64];
if (v >= 27) v -= 27;
_cachedPubkey = _ecRecover(
msgHash,
BigInt.parse(bytesToHex(sigBytes.sublist(0, 32)), radix: 16),
BigInt.parse(bytesToHex(sigBytes.sublist(32, 64)), radix: 16),
v,
);
return _cachedPubkey!;
}
Once you have the compressed public key, derive the address using your chain’s rules (SHA-256 + RIPEMD-160 for Bitcoin/Cosmos, keccak for Ethereum/Tron, etc.).
Ed25519 chains
Ed25519 chains use the Solana wallet’s public key directly. The Solana address is base58 encoding of the 32-byte Ed25519 public key:
import 'package:bs58/bs58.dart';
Uint8List getSolanaPublicKey() {
final solWallet = DynamicSDK.instance.wallets.userWallets
.firstWhere((w) => w.chain == 'SOL');
return Uint8List.fromList(base58.decode(solWallet.address));
}
Step 3: Sign payloads
The signing flow is the same for all chains on the same curve — only the hash/serialization before calling the SDK differs.
secp256k1 signing
Future<String> signWithEvmWallet(Uint8List digest32) async {
final evmWallet = DynamicSDK.instance.wallets.userWallets
.firstWhere((w) => w.chain == 'EVM');
// digest32 must be exactly 32 bytes, passed as hex without 0x
final signature = await DynamicSDK.instance.wallets.signRawMessage(
walletId: evmWallet.id,
accountAddress: evmWallet.address,
message: bytesToHex(digest32),
);
// Returns 65-byte hex (r + s + v) — strip 0x prefix if present
return strip0x(signature);
}
Many chains require low-S normalization. Apply this before encoding the signature:
final _secp256k1N = BigInt.parse(
'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141',
radix: 16,
);
BigInt normalizeLowS(BigInt s) {
final halfN = _secp256k1N >> 1;
return s > halfN ? _secp256k1N - s : s;
}
Ed25519 signing
Future<Uint8List> signWithSolanaWallet(String hexDigest) async {
final solWallet = DynamicSDK.instance.wallets.userWallets
.firstWhere((w) => w.chain == 'SOL');
final signer = DynamicSDK.instance.solana.createSigner(wallet: solWallet);
final result = await signer.signMessage(message: hexDigest);
// The result may be hex, base64, or base58 — normalize:
return decodeSig(result);
}
The decodeSig helper handles all three encodings:
import 'dart:convert';
import 'package:bs58/bs58.dart';
Uint8List decodeSig(String sig) {
final clean = sig.startsWith('0x') ? sig.substring(2) : sig.trim();
if (RegExp(r'^[0-9a-fA-F]{128}$').hasMatch(clean)) return hexToBytes(clean);
try {
final bin = base64.decode(clean);
if (bin.length == 64) return Uint8List.fromList(bin);
} catch (_) {}
return Uint8List.fromList(base58.decode(clean));
}
Step 4: Query balances and broadcast transactions
Use package:http to call the chain’s RPC or REST API. The signing flow is chain-agnostic; only the serialization and endpoint differ.
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> jsonRpc(
String url,
String method,
dynamic params,
) async {
final res = await http.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: json.encode({'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}),
);
final data = json.decode(res.body) as Map<String, dynamic>;
if (data['error'] != null) throw Exception(data['error']['message']);
return data['result'] as Map<String, dynamic>;
}
Shared byte utilities
Add these to lib/utils/bytes.dart:
import 'dart:typed_data';
String strip0x(String hex) => hex.startsWith('0x') ? hex.substring(2) : hex;
String bytesToHex(Uint8List bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
Uint8List hexToBytes(String hex) {
final clean = strip0x(hex);
final result = Uint8List(clean.length ~/ 2);
for (var i = 0; i < clean.length; i += 2) {
result[i ~/ 2] = int.parse(clean.substring(i, i + 2), radix: 16);
}
return result;
}
Uint8List concatBytes(List<Uint8List> arrays) {
final total = arrays.fold(0, (s, a) => s + a.length);
final result = Uint8List(total);
var offset = 0;
for (final arr in arrays) {
result.setAll(offset, arr);
offset += arr.length;
}
return result;
}
Checklist for a new chain
- Identify the curve (secp256k1 or Ed25519) and the root wallet to use
- Look up the address encoding (bech32, base58check, hex, etc.) in the chain’s spec
- Implement
deriveAddress(String rootWalletAddress) → String
- Implement
digestForSigning(String message) → String (hex of hash)
- Call
signWithEvmWallet or signWithSolanaWallet with the digest
- Post-process the signature if needed (low-S normalization, DER encoding, etc.)
- Serialize and broadcast the transaction via the chain’s RPC
See the existing chain implementations for complete examples:
Last modified on March 28, 2026