How to program a crypto wallet
A crypto wallet is a private key and some code that signs with it. Here is how to write one in JavaScript, and how to keep the key out of your app when real money is involved.
A crypto wallet is a private key and some code that uses the key to sign transactions. You can write one in a few lines of JavaScript with a library like ethers or viem. Writing the code is the easy part. The harder part is deciding where the private key lives once the wallet holds real money. On Lit Protocol you can create a wallet with one API call. The key is generated inside a secure enclave, only the code you permit can use it, and your app never holds the key.
I will start with a version you can run on your laptop, and then show how to move the key out of your app.
A wallet in a few lines of JavaScript
This uses ethers v6. It creates a new key and prints the address.
import { Wallet } from "ethers";
const wallet = Wallet.createRandom();
console.log(wallet.address); // give this out to receive funds
console.log(wallet.privateKey); // whoever has this controls the walletOnce the address has some ETH on it, you can send a transaction from it. This sends 0.001 ETH on Base.
import { Wallet, JsonRpcProvider, parseEther } from "ethers";
const provider = new JsonRpcProvider("https://mainnet.base.org");
const wallet = new Wallet(process.env.PRIVATE_KEY, provider);
const tx = await wallet.sendTransaction({ to: "0xRecipient...", value: parseEther("0.001") });
console.log(tx.hash);That is a working wallet. Every transaction goes through the same three steps. You build it, you sign it with the private key, and you send it to the network through an RPC node. viem does the same thing for EVM chains, @solana/web3.js does it for Solana, and bitcoinjs-lib builds and signs Bitcoin transactions.
The private key is the hard part
Look at where the key went in the second example. It is in an environment variable, and that is where most people put it. It works fine for testing. For a real app it has two problems. Anything that can read your server's memory or environment can copy the key. And nothing stands between your code and the signature, so a bug in your code signs whatever the bug produces.
The right place for the key depends on who owns the wallet. If you are building a wallet app where people hold their own funds, the key should stay on the user's device, protected by the phone's secure storage or a hardware wallet. If your app controls the wallet itself, like a payout service, a trading bot, or an AI agent, the key should live in a signer that your app calls over an API.
The same wallet on Lit
Go to the Lit dashboard and click Create an account. Choose API key, fill in your email and an account name, and copy the account API key it gives you. Add at least $5 of credit with Add Funds, and then create a wallet.
LIT=https://api.chipotle.litprotocol.com/core/v1
WALLET=$(curl -s -X POST "$LIT/create_wallet" -H "X-Api-Key: $ACCOUNT_KEY" | jq -r .wallet_address)
echo $WALLET # 0x...The key for that address was created inside the enclave. Only actions that you put in a group with the wallet can use it, and it only leaves the enclave if one of those actions sends it out. To use it, you write a Lit Action. A Lit Action is a JavaScript function that runs in the enclave with the key. This one sends ETH on Base, and it refuses to send more than 0.05 ETH at a time.
// send.js
async function main({ pkpId, to, amountEth }) {
if (Number(amountEth) > 0.05) return { sent: false, reason: "over the 0.05 ETH limit" };
const provider = new ethers.providers.StaticJsonRpcProvider("https://mainnet.base.org");
const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId }), provider);
const tx = await wallet.sendTransaction({ to, value: ethers.utils.parseEther(amountEth) });
return { sent: true, hash: tx.hash };
}The code inside an action uses ethers v5, so the syntax is a little different from the laptop version.
Lit knows an action by its content hash. You ask the API for the hash, put the action and the wallet in a group, and make an API key for your app that can only run actions in that group. The post on adding wallets to your app with an API explains each of these calls.
CID=$(jq -Rs . send.js | curl -s -X POST "$LIT/get_lit_action_ipfs_id" -H "Content-Type: application/json" -d @- | jq -r .)
GROUP=$(curl -s -X POST "$LIT/add_group" -H "X-Api-Key: $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d '{"group_name":"sender","group_description":"","pkp_ids_permitted":[],"cid_hashes_permitted":[]}' | jq -r .group_id)
curl -s -X POST "$LIT/add_action_to_group" -H "X-Api-Key: $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d "{\"group_id\":$GROUP,\"action_ipfs_cid\":\"$CID\"}"
curl -s -X POST "$LIT/add_pkp_to_group" -H "X-Api-Key: $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d "{\"group_id\":$GROUP,\"pkp_id\":\"$WALLET\"}"
USAGE_KEY=$(curl -s -X POST "$LIT/add_usage_api_key" -H "X-Api-Key: $ACCOUNT_KEY" -H "Content-Type: application/json" \
-d '{"name":"my-app","description":"","can_create_groups":false,"can_delete_groups":false,"can_create_pkps":false,
"manage_ipfs_ids_in_groups":[],"add_pkp_to_groups":[],"remove_pkp_from_groups":[],"execute_in_groups":['"$GROUP"']}' \
| jq -r .usage_api_key)Send some ETH on Base to the wallet address so it can pay for gas. Then your app sends a transaction by posting the action's code with its parameters.
jq -n --rawfile code send.js --arg wallet "$WALLET" '{code: $code, js_params: {pkpId: $wallet, to: "0xRecipient...", amountEth: "0.001"}}' \
| curl -s -X POST "$LIT/lit_action" -H "X-Api-Key: $USAGE_KEY" -H "Content-Type: application/json" -d @-
# {"response":{"sent":true,"hash":"0x..."},"logs":"","has_error":false}The enclave hashes the code and checks that your key is allowed to run that hash. When the action asks for the private key, the enclave checks that the wallet is in a group with that hash. New permissions can take up to 30 seconds to start working, so keep retrying the first call for a minute if it is refused.
Your app now holds a key that can run send.js and nothing else. It cannot read the private key, it cannot raise the limit, and it cannot sign with different code. If you want a different limit later, you change the code, which gives it a new hash. You add the new hash with add_action_to_group and remove the old one with remove_action_from_group. That call takes the action's hashed ID from list_actions, not the CID. A removal can take up to five minutes to take effect everywhere. The wallet address stays the same.
Which approach to use
If you are learning, or working on a testnet, the laptop version is fine. If your users hold their own funds, keep their keys on their devices or use an embedded wallet product with a login flow. If your app controls the wallet and you want the rules for signing to live next to the key, that is what Lit is for.
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 there are more actions you can copy at developer.litprotocol.com/lit-actions/examples.