Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
821dd3b
feat(account-keychain): implement admin access keys
legion2002 May 26, 2026
2ee0242
test(account-keychain): cover admin access key authorization
legion2002 May 26, 2026
6f6977f
fix(revm): allow admin auth across key types
legion2002 May 26, 2026
21df74a
test(node): add admin access key e2e coverage
legion2002 May 26, 2026
1a7026a
fix(account-keychain): bind admin-signed key auths
legion2002 May 26, 2026
53153f0
fix(account-keychain): preserve admin signer context
legion2002 May 26, 2026
8c3313e
fix(account-keychain): validate admin sidecar key type
legion2002 May 26, 2026
eb3b887
fix(account-keychain): price admin key auth validation
legion2002 May 26, 2026
3be88b7
fix(account-keychain): reject root key restrictions
legion2002 May 26, 2026
ed9182f
fix(txpool): evict revoked key authorization signers
legion2002 May 26, 2026
c8b6237
chore(keychain): address admin key review nits
legion2002 May 26, 2026
b92785e
fix(revm): enforce key authorization account binding
legion2002 May 26, 2026
3a9287e
fix(txpool): invalidate stale key authorizations
legion2002 May 26, 2026
f72bbdd
fix(account-keychain): address clippy and ABI checks
mattsse May 26, 2026
27366d5
chore: roll back tempo-std submodule bump
mattsse May 26, 2026
f13fb12
fix(revm): tighten T6 key authorization validation
legion2002 May 27, 2026
2089d9b
fix(revm): avoid pre-T6 access key recovery in state validation
legion2002 May 27, 2026
6e9b042
fix(precompiles): store signature type as enum
legion2002 May 27, 2026
8cbb647
chore: address keychain review comments
legion2002 May 27, 2026
742cc03
fix(account-keychain): make key auth account binding signer-based
legion2002 May 27, 2026
b3f0cbc
fix(account-keychain): charge admin key auth event buffer
legion2002 May 27, 2026
259efd5
refactor(revm): pipeline keychain authorization validation
legion2002 May 27, 2026
9ea5d27
fix(account-keychain): allow mutators on stored self-key rows
legion2002 May 27, 2026
c120f7f
refactor(account-keychain): reuse active key checks for admin status
legion2002 May 27, 2026
449461d
refactor(revm): move key authorization signer checks to env
legion2002 May 27, 2026
720bfac
fix(revm): collapse admin key authorization check
legion2002 May 27, 2026
46a95d4
fix(revm): require admin auth signer to match tx key
legion2002 May 28, 2026
775fd02
refactor(revm): streamline key authorization validation
legion2002 May 28, 2026
74ce9f8
refactor(account-keychain): share key authorization validation
legion2002 May 28, 2026
b4a0439
fix(account-keychain): scope self-key restriction to admin keys
legion2002 May 28, 2026
0661197
refactor(account-keychain): simplify admin key status errors
legion2002 May 28, 2026
11f243d
refactor(revm): cache key authorization signer
legion2002 May 28, 2026
d028e44
Merge branch 'main' into tip/1049-admin-access-keys
legion2002 May 28, 2026
feab627
refactor(primitives): cache key authorization signer on payload
legion2002 May 28, 2026
e7835d5
fix(revm): require root tx for root key auth
legion2002 May 28, 2026
f9d3f79
chore(transaction-pool): remove unused imports
legion2002 May 28, 2026
8042c99
chore(tip-1049): update tempo-std ABI
legion2002 May 28, 2026
cb7001b
Update crates/precompiles/src/account_keychain/mod.rs
legion2002 May 28, 2026
2e77030
Merge branch 'main' into tip/1049-admin-access-keys
legion2002 May 28, 2026
be191d7
chore(tip-1049): align tempo-std foundry lock
decofe May 28, 2026
c67f82a
chore(account-keychain): apply rustfmt
legion2002 May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changelog/admin-access-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
tempo-contracts: minor
tempo-primitives: minor
tempo-alloy: minor
---

Added T6 admin access key support for account keychain authorization and SDK transaction builders.
22 changes: 19 additions & 3 deletions crates/contracts/src/precompiles/account_keychain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

pub use IAccountKeychain::{
IAccountKeychainErrors as AccountKeychainError, IAccountKeychainEvents as AccountKeychainEvent,
authorizeKey_0Call as legacyAuthorizeKeyCall, authorizeKey_1Call as authorizeKeyCall,
authorizeKey_2Call as authorizeKeyWithWitnessCall, getAllowedCallsReturn,
getRemainingLimitWithPeriodCall, getRemainingLimitWithPeriodReturn as getRemainingLimitReturn,
authorizeAdminKeyCall, authorizeKey_0Call as legacyAuthorizeKeyCall,
authorizeKey_1Call as authorizeKeyCall, authorizeKey_2Call as authorizeKeyWithWitnessCall,
getAllowedCallsReturn, getRemainingLimitWithPeriodCall,
getRemainingLimitWithPeriodReturn as getRemainingLimitReturn,
};

crate::sol! {
Expand Down Expand Up @@ -78,6 +79,9 @@ crate::sol! {
/// Emitted when a new key is authorized
event KeyAuthorized(address indexed account, address indexed publicKey, uint8 signatureType, uint64 expiry);

/// Emitted when a new admin key is authorized.
event AdminKeyAuthorized(address indexed account, address indexed publicKey);

/// Emitted when a key is revoked
event KeyRevoked(address indexed account, address indexed publicKey);

Expand Down Expand Up @@ -126,6 +130,14 @@ crate::sol! {
bytes32 witness
) external;

/// Authorize a new admin key for the caller's account.
/// @dev The witness must not be burned for the caller's account. bytes32(0) is valid.
function authorizeAdminKey(
address keyId,
SignatureType signatureType,
bytes32 witness
) external;

/// Burn a TIP-1053 key-authorization witness without authorizing a key.
/// @dev Callable only by the account admin key.
function burnKeyAuthorizationWitness(bytes32 witness) external;
Expand Down Expand Up @@ -198,6 +210,9 @@ crate::sol! {
/// Returns whether a TIP-1053 key-authorization witness has been manually burned.
function isKeyAuthorizationWitnessBurned(address account, bytes32 witness) external view returns (bool);

/// Returns true if `keyId` is the root key or an active admin key for `account`.
function isAdminKey(address account, address keyId) external view returns (bool);

/// Get the key used in the current transaction
/// @return The keyId used in the current transaction
function getTransactionKey() external view returns (address);
Expand All @@ -216,6 +231,7 @@ crate::sol! {
error SignatureTypeMismatch(uint8 expected, uint8 actual);
error CallNotAllowed();
error InvalidCallScope();
error InvalidKeyId();
error InvalidKeyAuthorizationWitness();
error KeyAuthorizationWitnessAlreadyBurned();
error LegacyAuthorizeKeySelectorChanged(bytes4 newSelector);
Expand Down
220 changes: 219 additions & 1 deletion crates/node/tests/it/tempo_transaction/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ use tempo_chainspec::{hardfork::TempoHardfork, spec::TEMPO_T1_BASE_FEE};
use tempo_contracts::precompiles::{
DEFAULT_FEE_TOKEN,
account_keychain::IAccountKeychain::{
IAccountKeychainInstance, burnKeyAuthorizationWitnessCall, revokeKeyCall,
IAccountKeychainInstance, authorizeAdminKeyCall, burnKeyAuthorizationWitnessCall,
revokeKeyCall,
},
};
use tempo_precompiles::{
Expand All @@ -38,6 +39,7 @@ use tempo_precompiles::{
use tempo_primitives::{
TempoTransaction, TempoTxEnvelope,
transaction::{
KeyAuthorization, SignedKeyAuthorization,
tempo_transaction::Call,
tt_signature::{KeychainSignature, PrimitiveSignature, TempoSignature, WebAuthnSignature},
tt_signed::AASigned,
Expand All @@ -50,6 +52,36 @@ fn test_secp256k1_access_key_signature() -> TempoSignature {
TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()))
}

fn create_admin_key_authorization(
signer: &impl SignerSync,
admin_account: Address,
key_id: Address,
chain_id: u64,
) -> eyre::Result<SignedKeyAuthorization> {
let key_auth = KeyAuthorization::unrestricted(
chain_id,
tempo_primitives::SignatureType::Secp256k1,
key_id,
)
.into_admin(admin_account);
let signature = signer.sign_hash_sync(&key_auth.signature_hash())?;
Ok(key_auth.into_signed(PrimitiveSignature::Secp256k1(signature)))
}

fn authorize_admin_key_call(key_id: Address, witness: B256) -> Call {
Call {
to: ACCOUNT_KEYCHAIN_ADDRESS.into(),
value: U256::ZERO,
input: authorizeAdminKeyCall {
keyId: key_id,
signatureType: tempo_contracts::precompiles::IAccountKeychain::SignatureType::Secp256k1,
witness,
}
.abi_encode()
.into(),
}
}

/// Single-node local test environment with direct node access.
pub(crate) struct Localnet {
pub setup: SingleNodeSetup,
Expand Down Expand Up @@ -1496,6 +1528,192 @@ async fn test_key_authorization_witness_burn_evicts_pending_replay() -> eyre::Re
Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_t6_authorize_admin_key_abi_e2e() -> eyre::Result<()> {
reth_tracing::init_test_tracing();

let mut setup = TestNodeBuilder::new().build_with_node_access().await?;
let root_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?;
let root_addr = root_signer.address();
let provider = ProviderBuilder::new_with_network::<TempoNetwork>()
.wallet(root_signer.clone())
.connect_http(setup.node.rpc_url());
let chain_id = provider.get_chain_id().await?;

let admin_signer = PrivateKeySigner::random();
let admin_key = admin_signer.address();
let witness = B256::repeat_byte(0xa1);
let nonce = provider.get_transaction_count(root_addr).await?;
let tx = create_basic_aa_tx(
chain_id,
nonce,
vec![authorize_admin_key_call(admin_key, witness)],
2_000_000,
);

let sig = sign_aa_tx_secp256k1(&tx, &root_signer)?;
submit_and_mine_aa_tx(&mut setup, tx, sig).await?;

let keychain = IAccountKeychainInstance::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider);
assert!(
keychain.isAdminKey(root_addr, admin_key).call().await?,
"ABI authorizeAdminKey should register an active admin key"
);
assert!(
!keychain
.isKeyAuthorizationWitnessBurned(root_addr, witness)
.call()
.await?,
"authorizeAdminKey should check but not burn the witness"
);

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_t6_inline_admin_key_authorization_e2e() -> eyre::Result<()> {
reth_tracing::init_test_tracing();

let mut setup = TestNodeBuilder::new().build_with_node_access().await?;
let root_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?;
let root_addr = root_signer.address();
let provider = ProviderBuilder::new_with_network::<TempoNetwork>()
.wallet(root_signer.clone())
.connect_http(setup.node.rpc_url());
let chain_id = provider.get_chain_id().await?;

let admin_key = PrivateKeySigner::random().address();
let admin_auth = create_admin_key_authorization(&root_signer, root_addr, admin_key, chain_id)?;
let nonce = provider.get_transaction_count(root_addr).await?;
let mut tx = create_basic_aa_tx(
chain_id,
nonce,
vec![create_balance_of_call(root_addr)],
2_000_000,
);
tx.key_authorization = Some(admin_auth);

let sig = sign_aa_tx_secp256k1(&tx, &root_signer)?;
submit_and_mine_aa_tx(&mut setup, tx, sig).await?;

let keychain = IAccountKeychainInstance::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider);
assert!(
keychain.isAdminKey(root_addr, admin_key).call().await?,
"inline admin_account authorization should register an active admin key"
);

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_t6_admin_key_authorizes_child_admin_key_e2e() -> eyre::Result<()> {
reth_tracing::init_test_tracing();

let mut setup = TestNodeBuilder::new().build_with_node_access().await?;
let root_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?;
let root_addr = root_signer.address();
let provider = ProviderBuilder::new_with_network::<TempoNetwork>()
.wallet(root_signer.clone())
.connect_http(setup.node.rpc_url());
let chain_id = provider.get_chain_id().await?;

let admin_signer = PrivateKeySigner::random();
let admin_key = admin_signer.address();
let child_admin_key = PrivateKeySigner::random().address();

let root_nonce = provider.get_transaction_count(root_addr).await?;
let root_tx = create_basic_aa_tx(
chain_id,
root_nonce,
vec![authorize_admin_key_call(admin_key, B256::repeat_byte(0xa2))],
2_000_000,
);
let root_sig = sign_aa_tx_secp256k1(&root_tx, &root_signer)?;
submit_and_mine_aa_tx(&mut setup, root_tx, root_sig).await?;

let admin_signed_auth =
create_admin_key_authorization(&admin_signer, root_addr, child_admin_key, chain_id)?;
let mut admin_tx = create_basic_aa_tx(
chain_id,
provider.get_transaction_count(root_addr).await?,
vec![create_balance_of_call(root_addr)],
2_000_000,
);
admin_tx.key_authorization = Some(admin_signed_auth);
let admin_sig = sign_aa_tx_with_secp256k1_access_key(&admin_tx, &admin_signer, root_addr)?;
submit_and_mine_aa_tx(&mut setup, admin_tx, admin_sig).await?;

let keychain = IAccountKeychainInstance::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider);
assert!(
keychain
.isAdminKey(root_addr, child_admin_key)
.call()
.await?,
"admin access key should authorize a different admin key end-to-end"
);

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn test_t6_admin_key_authorization_cross_account_replay_rejected_e2e() -> eyre::Result<()> {
reth_tracing::init_test_tracing();

let mut setup = TestNodeBuilder::new().build_with_node_access().await?;
let alice_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC).build()?;
let alice_addr = alice_signer.address();
let bob_signer = MnemonicBuilder::from_phrase(TEST_MNEMONIC)
.index(1)?
.build()?;
let bob_addr = bob_signer.address();
let provider = ProviderBuilder::new()
.wallet(alice_signer.clone())
.connect_http(setup.node.rpc_url());
let chain_id = provider.get_chain_id().await?;

fund_address_with(
&mut setup,
&provider,
&alice_signer,
alice_addr,
bob_addr,
rand_funding_amount(),
DEFAULT_FEE_TOKEN,
chain_id,
)
.await?;

let replayed_admin_key = PrivateKeySigner::random().address();
let alice_bound_auth =
create_admin_key_authorization(&alice_signer, alice_addr, replayed_admin_key, chain_id)?;
let mut replay_tx = create_basic_aa_tx(
chain_id,
provider.get_transaction_count(bob_addr).await?,
vec![create_balance_of_call(bob_addr)],
2_000_000,
);
replay_tx.key_authorization = Some(alice_bound_auth);
let replay_sig = sign_aa_tx_secp256k1(&replay_tx, &bob_signer)?;
let replay_envelope: TempoTxEnvelope = replay_tx.into_signed(replay_sig).into();

let result = setup
.node
.rpc
.inject_tx(replay_envelope.encoded_2718().into())
.await;
assert!(
result.is_err(),
"admin_account-bound authorization for Alice must not be accepted by Bob"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("account mismatch") || err.contains("KeychainValidationFailed"),
"expected account mismatch rejection, got: {err}"
);

Ok(())
}

/// Verifies that transactions signed with a revoked access key cannot be executed.
#[tokio::test]
async fn test_aa_keychain_revocation_toctou_dos() -> eyre::Result<()> {
Expand Down
15 changes: 14 additions & 1 deletion crates/precompiles/src/account_keychain/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const T5_ADDED: &[[u8; 4]] = &[
IAccountKeychain::burnKeyAuthorizationWitnessCall::SELECTOR,
IAccountKeychain::isKeyAuthorizationWitnessBurnedCall::SELECTOR,
];
const T6_ADDED: &[[u8; 4]] = &[
IAccountKeychain::authorizeAdminKeyCall::SELECTOR,
IAccountKeychain::isAdminKeyCall::SELECTOR,
];

impl Precompile for AccountKeychain {
fn call(&mut self, calldata: &[u8], msg_sender: Address) -> PrecompileResult {
Expand All @@ -40,6 +44,7 @@ impl Precompile for AccountKeychain {
.with_added(T3_ADDED)
.with_dropped(T3_DROPPED),
SelectorSchedule::new(TempoHardfork::T5).with_added(T5_ADDED),
SelectorSchedule::new(TempoHardfork::T6).with_added(T6_ADDED),
],
IAccountKeychainCalls::abi_decode,
|call| match call {
Expand Down Expand Up @@ -92,6 +97,11 @@ impl Precompile for AccountKeychain {
)
})
}
IAccountKeychainCalls::authorizeAdminKey(call) => {
mutate_void(call, msg_sender, |sender, c| {
self.authorize_admin_key(sender, c.keyId, c.signatureType, Some(c.witness))
})
}
IAccountKeychainCalls::burnKeyAuthorizationWitness(call) => {
mutate_void(call, msg_sender, |sender, c| {
self.burn_key_authorization_witness(sender, c)
Expand Down Expand Up @@ -128,6 +138,9 @@ impl Precompile for AccountKeychain {
IAccountKeychainCalls::isKeyAuthorizationWitnessBurned(call) => {
view(call, |c| self.is_key_authorization_witness_burned(c))
}
IAccountKeychainCalls::isAdminKey(call) => {
view(call, |c| self.is_admin_key(c.account, c.keyId))
}
IAccountKeychainCalls::getTransactionKey(call) => {
view(call, |c| self.get_transaction_key(c, msg_sender))
}
Expand All @@ -154,7 +167,7 @@ mod tests {

#[test]
fn test_account_keychain_selector_coverage() -> eyre::Result<()> {
let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5);
let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
StorageCtx::enter(&mut storage, || {
let mut fee_manager = AccountKeychain::new();
let selectors: Vec<_> = IAccountKeychainCalls::SELECTORS
Expand Down
Loading
Loading