Skip to main content
The Account Keychain precompile enables accounts to authorize secondary keys (Access Keys) that can sign transactions with scoped permissions. This provides native session key functionality at the protocol level.

Address

0xAAAAAAAA00000000000000000000000000000000

Overview

Account Keychain allows accounts to:
  • Authorize multiple secondary keys (Access Keys) for signing transactions
  • Set expiry timestamps for each key
  • Define per-token spending limits that deplete as keys spend
  • Revoke keys to prevent replay attacks
  • Support multiple signature types (secp256k1, P256, WebAuthn)
Only the Root Key (the account’s main key) can manage Access Keys. This restriction is enforced by the protocol during transaction validation.

Interface

interface IAccountKeychain {
    enum SignatureType {
        Secp256k1,  // Standard Ethereum signatures
        P256,       // NIST P-256 (available T1C+)
        WebAuthn    // WebAuthn/passkey signatures
    }
    
    struct TokenLimit {
        address token;   // TIP-20 token address
        uint256 amount;  // Spending limit
    }
    
    struct KeyInfo {
        SignatureType signatureType;
        address keyId;
        uint64 expiry;
        bool enforceLimits;
        bool isRevoked;
    }
    
    // Management functions (Root Key only)
    function authorizeKey(
        address keyId,
        SignatureType signatureType,
        uint64 expiry,
        bool enforceLimits,
        TokenLimit[] calldata limits
    ) external;
    
    function revokeKey(address keyId) external;
    
    function updateSpendingLimit(
        address keyId,
        address token,
        uint256 newLimit
    ) external;
    
    // View functions
    function getKey(address account, address keyId) 
        external view returns (KeyInfo memory);
    
    function getRemainingLimit(
        address account,
        address keyId,
        address token
    ) external view returns (uint256);
    
    function getTransactionKey() external view returns (address);
}

Events

event KeyAuthorized(
    address indexed account,
    address indexed publicKey,
    uint8 signatureType,
    uint64 expiry
);

event KeyRevoked(
    address indexed account,
    address indexed publicKey
);

event SpendingLimitUpdated(
    address indexed account,
    address indexed publicKey,
    address indexed token,
    uint256 newLimit
);

Errors

error KeyAlreadyExists();
error KeyNotFound();
error KeyInactive();
error KeyExpired();
error KeyAlreadyRevoked();
error SpendingLimitExceeded();
error InvalidSignatureType();
error ZeroPublicKey();
error ExpiryInPast();
error UnauthorizedCaller();

Root Key vs Access Key

Root Key

The Root Key is the account’s main key (the one that created the account):
  • Can authorize and revoke Access Keys
  • Can update spending limits
  • Has unlimited spending power
  • Stored as address(0) in transaction context

Access Key

Access Keys are secondary keys with limited permissions:
  • Can sign transactions on behalf of the account
  • Subject to expiry timestamps
  • Subject to spending limits (if enforceLimits = true)
  • Cannot manage other keys

Authorization Flow

1. Root Key authorizes Access Key
   └─> Set expiry, limits, signature type
   
2. User signs transaction with Access Key
   └─> Protocol validates key and sets transaction_key in precompile
   
3. Transaction executes
   └─> TIP-20 transfers call precompile to check/update limits
   
4. Root Key can revoke at any time
   └─> Key becomes permanently unusable

Usage Examples

Authorizing a Key

contract MyAccount {
    IAccountKeychain constant KEYCHAIN = 
        IAccountKeychain(0xAAAAAAAA00000000000000000000000000000000);
    
    function authorizeSessionKey(
        address sessionKey,
        address usdcToken
    ) external {
        // Must be called by Root Key
        IAccountKeychain.TokenLimit[] memory limits = 
            new IAccountKeychain.TokenLimit[](1);
        limits[0] = IAccountKeychain.TokenLimit({
            token: usdcToken,
            amount: 1000e6  // 1000 USDC
        });
        
        KEYCHAIN.authorizeKey(
            sessionKey,
            IAccountKeychain.SignatureType.Secp256k1,
            uint64(block.timestamp + 7 days),
            true,  // enforceLimits
            limits
        );
    }
}

Using an Access Key

import { createWalletClient, http } from 'viem'

const rootKey = privateKeyToAccount('0x...')
const accessKey = privateKeyToAccount('0x...')

// 1. Root Key authorizes Access Key
await authorizeKey(rootKey, {
  keyId: accessKey.address,
  signatureType: 0, // Secp256k1
  expiry: Date.now() / 1000 + 86400, // 24 hours
  enforceLimits: true,
  limits: [{ token: USDC, amount: 100e6 }]
})

// 2. Access Key can now sign transactions
const client = createWalletClient({
  account: accessKey,
  chain: tempo,
  transport: http()
})

// This transfer is authorized by Access Key
// Spending limit is checked and updated
await transfer(client, {
  token: USDC,
  to: merchant,
  amount: 50e6  // 50 USDC
})

Checking Remaining Limits

function checkLimit(address account, address key, address token) 
    public view returns (uint256) 
{
    return KEYCHAIN.getRemainingLimit(account, key, token);
}

Revoking a Key

function revokeSessionKey(address sessionKey) external {
    // Must be called by Root Key
    KEYCHAIN.revokeKey(sessionKey);
    // Key is now permanently revoked and cannot be re-authorized
}

Spending Limits

How Limits Work

  1. Authorization: Root Key sets initial limits when authorizing key
  2. Depletion: Each transfer deducts from remaining limit
  3. Per-Token: Each token has independent limit
  4. Per-Transaction: Limits checked at transaction origin only

Limit Enforcement

Spending limits are enforced during TIP-20 token operations:
// Called by TIP-20 transfer()
keychain.authorize_transfer(msg.sender, token, amount)

// Called by TIP-20 approve()
keychain.authorize_approve(msg.sender, token, old_approval, new_approval)
Spending limits only apply when msg.sender == tx.origin. If a contract calls transfer(), the limit is not checked (the contract is spending its own tokens, not the user’s).

Approval Limits

Approvals consume spending limits based on the increase in allowance:
// Initial: allowance = 0, limit = 100
approve(spender, 30);  // limit = 70 (consumed 30)
approve(spender, 50);  // limit = 50 (consumed 20 more)
approve(spender, 20);  // limit = 50 (decreased, no consumption)

Unlimited Keys

Keys with enforceLimits = false have no spending restrictions:
KEYCHAIN.authorizeKey(
    sessionKey,
    SignatureType.Secp256k1,
    expiry,
    false,  // enforceLimits = false
    []      // no limits needed
);

Signature Types

Secp256k1 (Type 0)

Standard Ethereum signatures:
const signature = await account.signMessage({ message: hash })

P256 (Type 1, T1C+)

NIST P-256 signatures for secure enclaves:
// Available starting from T1C hardfork
const p256Key = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  true,
  ['sign', 'verify']
)

WebAuthn (Type 2)

Passkey/biometric signatures:
const credential = await navigator.credentials.create({
  publicKey: {
    challenge: new Uint8Array(32),
    rp: { name: 'My App' },
    user: { id: userId, name: userName, displayName: userName },
    pubKeyCredParams: [{ type: 'public-key', alg: -7 }]
  }
})

Replay Protection

Revoked keys cannot be re-authorized:
// 1. Authorize key
authorizeKey(key1, ...);

// 2. Revoke key
revokeKey(key1);

// 3. Try to re-authorize - FAILS
authorizeKey(key1, ...);  // Reverts with KeyAlreadyRevoked
This prevents replay attacks where an old authorization signature could be reused.

Gas Costs

OperationColdWarm
authorizeKey~90,000 gas~45,000 gas
revokeKey~30,000 gas~15,000 gas
updateSpendingLimit~30,000 gas~15,000 gas
getKey~3,000 gas~400 gas
getRemainingLimit~2,500 gas~300 gas
Spending limit check~6,000 gas~2,000 gas

Storage Layout

// Slot 0: keys[account][keyId] -> AuthorizedKey (packed)
// Packed fields: signature_type (1 byte) + expiry (8 bytes) + 
//                enforce_limits (1 byte) + is_revoked (1 byte)
mapping(address => mapping(address => AuthorizedKey)) keys;

// Slot 1: spendingLimits[hash(account,keyId)][token] -> amount
mapping(bytes32 => mapping(address => uint256)) spending_limits;

// Transient storage (cleared after transaction)
address transaction_key;  // Key used in current transaction
address tx_origin;        // Transaction origin
AuthorizedKey is packed into a single storage slot for gas efficiency.

Protocol Integration

Transaction Validation

The protocol calls the precompile during transaction validation:
// 1. Determine which key signed the transaction
let key_id = extract_key_from_signature(&tx);

// 2. Set transaction key in precompile
keychain.set_transaction_key(key_id)?;
keychain.set_tx_origin(tx.origin)?;

// 3. Validate key authorization
if key_id != Address::ZERO {
    keychain.validate_keychain_authorization(
        tx.sender,
        key_id,
        current_timestamp,
        signature_type
    )?;
}

// 4. Execute transaction
// (spending limits checked during TIP-20 transfers)

Transient Storage

The transaction_key is stored in transient storage (EIP-1153) and automatically cleared after transaction execution:
pub fn set_transaction_key(&mut self, key_id: Address) -> Result<()> {
    self.transaction_key.t_write(key_id)  // Transient write
}
This ensures keys don’t leak between transactions.

Best Practices

Key Expiry

// ✅ Good: Short-lived session keys
expiry: block.timestamp + 1 days

// ❌ Bad: Very long expiry (use revocation instead)
expiry: block.timestamp + 365 days

// ✅ Good: Never expires (use type(uint64).max)
expiry: type(uint64).max

Spending Limits

// ✅ Good: Per-token limits
limits: [
    { token: USDC, amount: 1000e6 },
    { token: USDT, amount: 1000e6 }
]

// ❌ Bad: Single token with huge limit
limits: [{ token: USDC, amount: 1000000e6 }]

// ✅ Good: Unlimited for trusted keys
enforceLimits: false

Key Management

// Store key metadata off-chain
interface KeyMetadata {
  keyId: Address
  purpose: string  // "trading", "gaming", etc
  createdAt: number
  expiresAt: number
}

// Periodically cleanup expired keys
async function cleanupExpiredKeys() {
  const keys = await getAuthorizedKeys(account)
  const now = Date.now() / 1000
  
  for (const key of keys) {
    if (key.expiry < now && !key.isRevoked) {
      await revokeKey(key.keyId)
    }
  }
}

Security Considerations

  • Root Key Protection: Root key has full control; compromise = full account compromise
  • Key Expiry: Always set expiry; use revocation for immediate invalidation
  • Spending Limits: Set conservative limits; monitor usage
  • Contract Calls: Spending limits don’t apply to contract-initiated transfers
  • Replay Protection: Revoked keys cannot be re-authorized (prevents replay)

Use Cases

Gaming Session Keys

// Authorize gaming key with token limits
await authorizeKey(rootKey, {
  keyId: gamingKey,
  expiry: Date.now() / 1000 + 3600, // 1 hour
  limits: [{ token: GAME_TOKEN, amount: 100e18 }]
})

// Gaming client uses session key
while (playing) {
  await buyItem(gamingKey, itemId)
}

Mobile Wallet

// Authorize mobile key with daily spending limit
await authorizeKey(rootKey, {
  keyId: mobileKey,
  expiry: Math.floor(Date.now() / 1000) + 86400 * 30, // 30 days
  limits: [
    { token: USDC, amount: 500e6 },  // $500/day USDC
    { token: USDT, amount: 500e6 }   // $500/day USDT
  ]
})

// Reset daily by updating limit
setInterval(async () => {
  await updateSpendingLimit(rootKey, mobileKey, USDC, 500e6)
  await updateSpendingLimit(rootKey, mobileKey, USDT, 500e6)
}, 86400 * 1000)

Hardware Wallet + Hot Key

// Hardware wallet = Root Key (cold storage)
// Hot key = Access Key (daily operations)

await authorizeKey(hardwareWallet, {
  keyId: hotKey,
  expiry: type(uint64).max,  // Never expires
  limits: [{ token: USDC, amount: 10000e6 }]  // $10k limit
})

// Hot key handles daily operations
await transfer(hotKey, { ... })

// Hardware wallet for large transfers
await transfer(hardwareWallet, { ... })  // Unlimited

See Also