Keyless backends
A service can sign Solana transactions with no private key in its environment, from an account nothing on chain ties to your wallet.
A bot or a keeper needs to sign transactions. With keyless on, your service signs through a signer that runs beside your app in the same microVM. The key it signs with is made at boot, lives only in memory, and gets its authority from a zero-knowledge proof. The account it acts for comes from a salted commitment, so nothing on chain ties it to your wallet. A policy you set bounds what it can spend.
How it works
Keyless follows Sui's zkLogin, with your service in place of the browser and the cluster in place of Google.
| zkLogin | wabi keyless |
|---|---|
| Ephemeral key made in the browser | Ephemeral key made in your service's microVM at boot |
| JWT from Google, its nonce committing to the ephemeral key | Workload token from the cluster's issuer for this service, deployment and image, its nonce committing to the ephemeral key |
| Address from a hash of the issuer, the app, the user and a salt | Account from a commitment to your wallet, the service and a salt |
| Validators verify a Groth16 proof | The wabi_keyless program verifies a Groth16 proof |
Step by step:
- At boot, the signer generates an Ed25519 key pair. It exists only in the VM's memory. The signer runs in its own container, so your app can't read that memory.
- The signer asks the cluster's issuer for a workload token. The issuer signs one only for an instance running this service's deployment, and binds the service, the deployment, the owner and the image digest into it. The token's nonce commits to the ephemeral key with a random blind, so the issuer never sees the key or the salt and can't tell which account an instance opens.
- Inside the VM, the signer makes a Groth16 proof, in about half a second: the token verifies under the issuer's key, its nonce commits to the ephemeral key, and the owner, the service and the salt hash to the account's commitment. The token, the salt, the owner and the service stay private.
open_sessiononwabi_keylessverifies the proof once and records the ephemeral key as the account's session key until the token's expiry. The signer renews the session with the same key when two thirds of it have passed.- Each transaction your code sends is an
execute, signed by the session key. The program runs your instructions with the service's wallet as signer, within your policy. One proof covers every transaction of the session.
The account and the wallet
| Address | Seeds | What it is |
|---|---|---|
| Account | ["account", commitment, policy_key] | the state: commitment, policy key, session key and expiry, policy, spend counters, paused |
| Wallet | ["wallet", account] | a system account with no data that holds the service's SOL, owns its token accounts and signs inside execute |
The commitment is a Poseidon hash of your wallet, the service id and the salt. Neither address's seeds contain your wallet. The policy key in the account's seeds means anyone who copies a commitment gets an address of their own. The wallet is your service's on-chain identity: use it wherever your instructions need a signer or a funder.
Turn it on
Keyless is available on web and worker services. When you turn it on for a service in the dashboard, your wallet signs one message for that service:
wabi keyless account v1
service: <serviceId>
wallet: <base58 owner wallet>
Signing this gives control of this service's keyless account. Sign it only on wabi.From that signature your browser derives two values:
- the salt, which goes to the service as the sealed variable
WABI_KEYLESS_SALT, - the policy key, an Ed25519 key pair. Its public half goes to the service as the sealed variable
WABI_KEYLESS_POLICY. Its secret never leaves your browser.
Both join a new variables version your wallet signs, so no one else can point the signer at another account. Only the signer opens them; they never enter your app's environment (Sealed variables). Your browser then creates the account on chain, signed by the policy key, checks that the account's policy key is the derived one, and shows the wallet address to fund.
The same wallet always derives the same salt and policy key for a service, so your browser derives the policy key again whenever it needs to sign, and nothing stores it. Each service gets its own pair, so your keyless accounts aren't linkable to each other either.
The signer's 0.5 vCPU and 128 MB count toward your plan alongside the service. Port 7461 is the signer's, so a keyless service listens on another port. The signer runs with your service's instances; builds and pre-deploy commands run without one.
Fund the wallet
The dashboard shows the service's wallet address. Send it the SOL your code spends and pays fees with, and any tokens it uses.
Where the funds come from decides what the chain shows. Sending from your connected wallet, or withdrawing back to it, links the two on chain. Funding from another wallet, or straight from an exchange withdrawal, doesn't.
When a session opens, the program tops the session key up from the wallet to the policy's gas float, and every execute tops it back up. While the session key can't pay fees, the signer shows unfunded; once the wallet holds enough, it refuels the session. When an instance stops, its signer sends what the session key holds back to the wallet.
Policy
| Field | What it bounds | Limit |
|---|---|---|
gasFloat | lamports kept on the session key for fees | 1,000,000 to 100,000,000 lamports (0.001 to 0.1 SOL) |
programs | the programs execute may call | up to 8, never wabi_keyless itself |
sol.perTx, sol.perDay | lamports leaving the wallet, per transaction and per day | your values |
tokens | for each mint, atoms leaving the wallet's token accounts, per transaction and per day | up to 4 mints |
- Spend is measured, not parsed.
executereads the wallet's lamports and every writable token account the wallet owns before your instructions run and again after. A drop counts against that mint's limits. A drop in a mint with no entry is refused. - A day is a UTC day.
- Topping the session key back up to its gas float counts against
sol.perDay. - After your instructions, the wallet must still be a system account with no data, and its token accounts must keep the wallet as owner, with no delegate and no other close authority. Only the wallet may sign inside
execute. - Closing one of the wallet's token accounts counts its rent as SOL spend, unless the lamports go back to the wallet.
You change the policy in the dashboard, and your browser signs the change with the policy key. Spend counters of mints that stay in the policy carry over.
Use it from your code
The SDK is @wabi/keyless, an ES module with @solana/web3.js 1.x as a peer. KeylessSigner talks to the signer:
| Method | What it does |
|---|---|
new KeylessSigner() | connects to WABI_SIGNER_URL |
ready() | waits until the session is open, then returns the account, including wallet |
account() | returns the signer's current state without waiting |
sendTransaction(connection, instructions) | wraps the instructions into one execute, has the signer sign it, sends it and confirms it; returns the signature |
sendTransaction also takes a web3.js Transaction, and options for compute units, priority fee, lookup tables and commitment.
A payout from the service's wallet. RPC_URL and PAYOUT_ADDRESS are this example's own variables:
import { Connection, PublicKey, SystemProgram } from "@solana/web3.js";
import { KeylessSigner } from "@wabi/keyless";
const connection = new Connection(process.env.RPC_URL!, "confirmed");
const signer = new KeylessSigner();
const { wallet } = await signer.ready();
const signature = await signer.sendTransaction(connection, [
SystemProgram.transfer({
fromPubkey: new PublicKey(wallet),
toPubkey: new PublicKey(process.env.PAYOUT_ADDRESS!),
lamports: 5_000_000,
}),
]);
console.log(`payout sent: ${signature}`);The transfer calls the System Program, so the policy's programs include 11111111111111111111111111111111, and its SOL limits cover 5,000,000 lamports.
The signer endpoint
The signer listens on http://127.0.0.1:7461, given to your app as WABI_SIGNER_URL. It is bound to loopback, so only your service's own instance reaches it.
| Call | Body | Returns |
|---|---|---|
GET /v1/health | 200 ok once the signer runs | |
GET /v1/account | SignerAccount | |
POST /v1/sign | { transaction: base64 } | { transaction: base64 } signed by the session key; 409 no_session, 400 refused: <reason> |
/v1/sign signs only a transaction whose fee payer is the session key and whose instructions are ComputeBudget or execute for this account. Your code spends only through execute, within the policy.
interface SignerAccount {
programId: string;
account: string | null; // Account address; null until the first token
wallet: string | null; // Wallet address: the service's on-chain identity
sessionKey: string; // fee payer of every transaction it signs
expiresAt: string | null; // ISO time, once a session is open
state: "starting" | "open" | "unfunded" | "paused" | "replaced" | "error";
message?: string; // the reason, for unfunded, paused, replaced and error
}| State | Meaning |
|---|---|
starting | the key is made; token, proof and session are on the way |
open | the session is open and the signer signs |
unfunded | the session key can't pay fees; the signer refuels once the wallet holds enough |
paused | the account is paused or revoked |
replaced | a newer instance's session holds the account |
error | message says what happened |
Rolling updates
An account has one session at a time. During a rolling update, the new instance opens its own session, which replaces the old instance's. The old instance shows replaced and takes the account back only after the newer session has ended, so the two never trade it back and forth. A replayed proof can't take an account back either: a session only moves to a later expiry.
Pause, revoke and withdraw
These run from the dashboard, signed by the policy key in your browser:
- Pausing stops the session key's
executecalls until you unpause. - Revoking ends the session at once and pauses the account. The revoked session key never opens a session again; after you unpause, a redeploy brings a new key.
- Withdrawing runs an owner
executesigned by the policy key. It has no policy limits and works while the account is paused, so the funds of a revoked or deleted service stay within your reach.
Withdrawing to your connected wallet links the two on chain, as with funding.
Who pays fees
| Transaction | Fee payer |
|---|---|
| creating the account, policy changes, revoke, owner withdrawals | wabi's keyless relayer |
the first open_session after boot | wabi's keyless relayer |
session renewals and every execute | the session key, from its gas float |
The relayer also pays the account's rent, about 0.006 SOL. It is one wallet for every customer, so paying for an account says nothing about whose it is. Your own wallet never signs or pays for a keyless transaction, and wabi keeps no record that links your service to its account.