How to make a wallet that works on Ethereum and Solana
Ethereum and Solana use different kinds of keys, so a wallet for both needs two keys or one seed that makes both. Here is how that works, and how to do it on Lit with one piece of code that signs for both chains.
Ethereum and Solana use different kinds of keys. Ethereum uses secp256k1 keys and addresses that start with 0x. Solana uses ed25519 keys and base58 addresses. So a wallet that works on both chains holds two keys, or one secret that both keys are made from. On Lit Protocol, every Lit Action has its own key, which lives inside a secure enclave. That key works as an Ethereum key as it is, and the same 32 bytes work as the seed for a Solana key. One action can sign for both chains, and the key never leaves the enclave.
First I will explain how wallets usually do this, and then show the Lit version.
How most wallets do it
Most wallets start from one seed phrase, the 12 or 24 words you write down. A standard called BIP-44 turns that seed into many keys, one for each chain and account, by following a path. Ethereum's path starts with m/44'/60' and Solana's starts with m/44'/501'. The seed makes an Ethereum key on the first path and a Solana key on the second. Libraries like ethers and viem can derive the Ethereum key from the seed. For Solana, wallets derive the key with a standard called SLIP-0010, using a library like ed25519-hd-key, and then pass it to Keypair.fromSeed in @solana/web3.js.
That is the right design when a user holds the seed on their own device. When your app controls the wallet, the seed becomes the problem. It controls every chain at once, and it has to be stored somewhere your code can read it.
One action, two chains
A Lit Action is a JavaScript function that runs inside a secure enclave. Lit.Actions.getLitActionPrivateKey() returns a key derived from a hash of the action's code. That key is secp256k1, so ethers can use it directly for Ethereum. It is also 32 bytes, which is exactly the size of an ed25519 seed, and that is what Solana's Keypair.fromSeed uses. So the same key gives you both addresses.
// multichain.js
import * as ed from "@noble/ed25519@2.1.0";
import { sha512 } from "@noble/hashes@1.4.0/sha512/+esm";
import { base58 } from "@scure/base@1.1.6";
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
const MAX_ETH = "0.05"; // the most it sends on Ethereum in one transaction
const MAX_LAMPORTS = 500_000_000n; // the most it sends on Solana, 0.5 SOL
async function main({ op, to, amountEth, message, recipient }) {
const key = await Lit.Actions.getLitActionPrivateKey();
const seed = ed.etc.hexToBytes(key.replace(/^0x/, ""));
const solPublicKey = ed.getPublicKey(seed);
if (op === "addresses") {
return { ethereum: new ethers.Wallet(key).address, solana: base58.encode(solPublicKey) };
}
if (op === "send-eth") {
if (Number(amountEth) > Number(MAX_ETH)) return { sent: false, reason: "over the limit" };
const provider = new ethers.providers.StaticJsonRpcProvider("https://ethereum-rpc.publicnode.com");
const wallet = new ethers.Wallet(key, provider);
const tx = await wallet.sendTransaction({ to, value: ethers.utils.parseEther(amountEth) });
return { sent: true, hash: tx.hash };
}
if (op === "sign-sol") {
const bytes = ethers.utils.base64.decode(message);
const check = checkSolanaTransfer(bytes, solPublicKey, recipient, MAX_LAMPORTS);
if (!check.ok) return { signed: false, reason: check.reason };
return { signed: true, signature: ethers.utils.base64.encode(ed.sign(bytes, seed)) };
}
}The imports are pinned to exact versions, and Lit checks each one against an integrity hash before it runs. The limits are written into the code, so a caller cannot change them.
On Ethereum, the action builds, signs, and sends the transaction itself. On Solana it works a little differently. Your app builds the transaction with @solana/web3.js and sends the message bytes to the action. The action reads them to make sure the transaction is a single transfer from its own address, for no more than 0.5 SOL, to the recipient you named. Then it signs those exact bytes.
checkSolanaTransfer is that check, and this post does not write it out. Copy the message parser and the checks from solanaSigner.js in Lit's Solana signer example into a function with that name that returns { ok, reason }. Do this before you ask the action for its addresses. Any change to the file changes both addresses, so the file you fund has to be the finished one. The example also has setup and transfer scripts that run on devnet.
Using it from your app
Setup is the same as for any action. You get the action's content hash, add it to a group, and make an API key that can only run that group. The API reference has each call. Then ask the action for its addresses and fund both. New permissions can take up to 30 seconds to start working, so keep retrying the first call for a minute if it is refused.
jq -n --rawfile code multichain.js '{code: $code, js_params: {op: "addresses"}}' \
| curl -s -X POST https://api.chipotle.litprotocol.com/core/v1/lit_action \
-H "X-Api-Key: $USAGE_KEY" -H "Content-Type: application/json" -d @-
# {"response":{"ethereum":"0x...","solana":"7xK..."},...}To send SOL, your app builds the transaction, gets the signature from the action, attaches it, and broadcasts it.
import { Connection, PublicKey, Transaction, SystemProgram } from "@solana/web3.js";
const connection = new Connection("https://api.mainnet-beta.solana.com");
const from = new PublicKey(SOLANA_ADDRESS);
const { blockhash } = await connection.getLatestBlockhash();
const tx = new Transaction({ feePayer: from, recentBlockhash: blockhash })
.add(SystemProgram.transfer({ fromPubkey: from, toPubkey: new PublicKey(recipient), lamports: 10_000_000 }));
const { response } = await runAction({ op: "sign-sol", message: tx.serializeMessage().toString("base64"), recipient });
if (!response.signed) throw new Error(response.reason);
tx.addSignature(from, Buffer.from(response.signature, "base64"));
console.log(await connection.sendRawTransaction(tx.serialize()));SOLANA_ADDRESS is the Solana address the action returned, and recipient is the address you are paying. runAction is a small function that posts the code and parameters to /lit_action with your API key, like the curl call above.
One thing to plan for
The key comes from the code, so both addresses belong to this exact version of the action. If you change the code, you get two new addresses. So every version needs a way to move its funds out, and that way has to be in the version you fund, because you cannot change that version later. In this file, send-eth and sign-sol do that job, one capped transfer at a time.
If you need a separate wallet for each user, you can stamp the user's ID into the action's code. Each user then gets their own action, their own key, and their own pair of addresses. The docs call these derived actions.
Get started
To create an account, go to the Lit dashboard and click Create an account. The quickstart is at developer.litprotocol.com/quickstart, and importing packages into an action is explained at developer.litprotocol.com/lit-actions/imports.