Build with Altana
Authorize across chains
grantSession on L1, ensureKeyCached to mirror, then verify from L2.How it works
Sessions are granted on L1 (Ethereum, the Keystore source of truth). An L2 cache on Base can verify that same session via a storage proof against L1 state, without any bridge message or re-granting.
ensureKeyCached handles the proof:
- Reads the L2 cache. If the key is already there, returns immediately (
cache-hit). - Polls the L2's
L1Blockpredeploy until it anchors past the relevant L1 block (waiting-for-anchor, typically 1–3 min) - Fetches an
eth_getProoffrom L1 and submits it to the L2 cache (submitting-proof) - Returns once the cache confirms the key is live (
done)
After step 4, any tool on Base can call isValidKey on the cache, for free, from any RPC.
The three steps below follow the flow above: grant on L1, mirror to the L2 cache, then verify on L2.
Step 1: Grant the session on Ethereum (L1)
Sessions are granted on L1, the Keystore source of truth. This is the same grantSession you have already used, pointed at Ethereum.
import {
createClient,
ensureKeyCached,
ETHEREUM,
BASE,
signerFromPrivateKey,
} from "@altananetwork/sdk";
import { createPublicClient, createWalletClient, http } from "viem";
import { mainnet, base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
// 1. Grant the session on Ethereum (L1, source of truth).
const client = createClient({ chains: [ETHEREUM] });
const signer = signerFromPrivateKey(process.env.PRIVATE_KEY as `0x${string}`);
const wallet = await client.createWallet({ signer });
const session = await client.grantSession({
wallet,
signer,
permissions: {
calls: [{ to: "0xSomeContract..." }],
spend: [{ limit: 50_000_000_000_000_000n, period: "day" }],
},
expiry: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
});Step 2: Mirror the session to the Base L2 cache
ensureKeyCached proves the L1 authorization into an L2 cache. It waits for Base to anchor past the L1 block, submits the storage proof, and returns once the key is live. Any funded account can pay the L2 gas.
// 2. Mirror the session key to the Base L2 cache.
const l1Client = createPublicClient({ chain: mainnet, transport: http(ETHEREUM.publicRpcUrl) });
const l2Client = createPublicClient({ chain: base, transport: http(BASE.publicRpcUrl) });
// The L2 wallet client pays L2 gas to submit the proof.
// Any funded account works — your own EOA, a backend relayer, etc.
const l2WalletClient = createWalletClient({
account: privateKeyToAccount(process.env.RELAYER_KEY as `0x${string}`),
chain: base,
transport: http(BASE.publicRpcUrl),
});
await ensureKeyCached({
l1Client,
l2Client,
l2WalletClient,
l1KeyStore: ETHEREUM.keyStore,
l2Cache: BASE.keyStoreCache,
user: session.walletAddress,
publicKey: session.publicKey,
onStatus: (s) => console.log(s), // cache-hit | waiting-for-anchor | submitting-proof | done
});Step 3: Verify the session from Base
Once cached, any tool on Base can confirm the session with a single free read, with no L1 round trip.
// 3. Verify the session from Base — free, from any RPC.
const { keccak256 } = await import("viem");
const CACHE_ABI = [{
name: "isValidKey", type: "function", stateMutability: "view",
inputs: [
{ name: "user", type: "address" },
{ name: "keyId", type: "bytes32" },
],
outputs: [{ type: "bool" }],
}] as const;
const keyId = keccak256(session.publicKey);
const isValid = await l2Client.readContract({
address: BASE.keyStoreCache,
abi: CACHE_ABI,
functionName: "isValidKey",
args: [session.walletAddress, keyId],
});
console.log("Session valid on Base:", isValid); // trueWhy it is different
Authorization crosses chains by storage proof, permissionlessly, with no bridge message and no per-chain re-granting. The first cross-chain action pays the proof cost once; after that, the L2 cache serves reads locally without L1 round-trips.
What's next
Browse all guides: see the full map of what you can build with Altana.