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.

zkLoginwabi keyless
Ephemeral key made in the browserEphemeral key made in your service's microVM at boot
JWT from Google, its nonce committing to the ephemeral keyWorkload 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 saltAccount from a commitment to your wallet, the service and a salt
Validators verify a Groth16 proofThe wabi_keyless program verifies a Groth16 proof

Step by step:

  1. 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.
  2. 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.
  3. 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.
  4. open_session on wabi_keyless verifies 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.
  5. 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

AddressSeedsWhat 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

FieldWhat it boundsLimit
gasFloatlamports kept on the session key for fees1,000,000 to 100,000,000 lamports (0.001 to 0.1 SOL)
programsthe programs execute may callup to 8, never wabi_keyless itself
sol.perTx, sol.perDaylamports leaving the wallet, per transaction and per dayyour values
tokensfor each mint, atoms leaving the wallet's token accounts, per transaction and per dayup to 4 mints
  • Spend is measured, not parsed. execute reads 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:

MethodWhat 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.

CallBodyReturns
GET /v1/health200 ok once the signer runs
GET /v1/accountSignerAccount
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
}
StateMeaning
startingthe key is made; token, proof and session are on the way
openthe session is open and the signer signs
unfundedthe session key can't pay fees; the signer refuels once the wallet holds enough
pausedthe account is paused or revoked
replaceda newer instance's session holds the account
errormessage 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 execute calls 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 execute signed 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

TransactionFee payer
creating the account, policy changes, revoke, owner withdrawalswabi's keyless relayer
the first open_session after bootwabi's keyless relayer
session renewals and every executethe 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.