diff --git a/crates/contracts/src/lib.rs b/crates/contracts/src/lib.rs index 87e17f58e7..e227ef9ce1 100644 --- a/crates/contracts/src/lib.rs +++ b/crates/contracts/src/lib.rs @@ -15,6 +15,24 @@ pub const PERMIT2_SALT: B256 = pub const ARACHNID_CREATE2_FACTORY_ADDRESS: Address = address!("0x4e59b44847b379578588920cA78FbF26c0B4956C"); +/// TIP-1018: Cross-chain deterministic account factory address. +/// Deployed via the Arachnid CREATE2 factory at an identical address on all EVM chains. +/// This address is used to compute CREATE2-based v2 account addresses from passkey coordinates. +/// +/// NOTE: This is a placeholder until the factory is deployed. The real address will be +/// determined by deploying `CrossChainAccountFactory` via `ARACHNID_CREATE2_FACTORY_ADDRESS`. +pub const CROSS_CHAIN_ACCOUNT_FACTORY_ADDRESS: Address = + address!("0xCC0A000000000000000000000000000000001018"); + +/// TIP-1018: The keccak256 hash of the `CrossChainAccount` creation code (no constructor args). +/// This MUST be frozen — any change produces different addresses and breaks cross-chain identity. +/// +/// Computed from: `keccak256(type(CrossChainAccount).creationCode)` with solc 0.8.28, via_ir, 200 runs. +/// +/// NOTE: This is a placeholder until the contract bytecode is finalized and audited. +pub const CROSS_CHAIN_ACCOUNT_INIT_CODE_HASH: B256 = + b256!("0x0000000000000000000000000000000000000000000000000000000000000000"); + /// Helper macro to allow feature-gating rpc implementations behind the `rpc` feature. macro_rules! sol { ($($input:tt)*) => { diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 82eb0bb65b..666db426aa 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -37,6 +37,9 @@ modular-bitfield = { version = "0.13.1", optional = true } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +# Tempo +tempo-contracts.workspace = true + # Cryptography for P256 and WebAuthn signature verification p256 = { workspace = true, features = ["ecdsa"] } sha2.workspace = true diff --git a/crates/primitives/src/transaction/tt_signature.rs b/crates/primitives/src/transaction/tt_signature.rs index 42d8a56881..b1551ffc61 100644 --- a/crates/primitives/src/transaction/tt_signature.rs +++ b/crates/primitives/src/transaction/tt_signature.rs @@ -681,7 +681,12 @@ impl From for TempoSignature { // Helper Functions for Signature Verification // ============================================================================ -/// Derives a P256 address from public key coordinates +/// Derives a v1 P256 address from public key coordinates (legacy derivation). +/// +/// Formula: `keccak256(pubKeyX || pubKeyY)[12:]` +/// +/// This is the original Tempo address derivation for P256/WebAuthn accounts. +/// It produces addresses that are only meaningful on the Tempo chain. pub fn derive_p256_address(pub_key_x: &B256, pub_key_y: &B256) -> Address { let hash = keccak256([pub_key_x.as_slice(), pub_key_y.as_slice()].concat()); @@ -689,6 +694,92 @@ pub fn derive_p256_address(pub_key_x: &B256, pub_key_y: &B256) -> Address { Address::from_slice(&hash[12..]) } +/// Derives a v2 CREATE2-based address from public key coordinates (TIP-1018). +/// +/// Formula: `keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:]` +/// where `salt = keccak256(pubKeyX || pubKeyY || index)` +/// +/// This derivation produces addresses that are identical across all EVM chains, +/// enabling cross-chain fund recovery by deploying a smart wallet at the +/// counterfactual address. +/// +/// # Arguments +/// * `pub_key_x` - The x-coordinate of the P-256 public key +/// * `pub_key_y` - The y-coordinate of the P-256 public key +/// * `index` - Account index (default 0), allows multiple wallets per passkey +/// * `factory` - The CrossChainAccountFactory address (must be identical on all chains) +/// * `init_code_hash` - The keccak256 hash of the CrossChainAccount creation code +pub fn derive_v2_address( + pub_key_x: &B256, + pub_key_y: &B256, + index: U256, + factory: &Address, + init_code_hash: &B256, +) -> Address { + // salt = keccak256(pubKeyX || pubKeyY || index) + let salt = keccak256( + [ + pub_key_x.as_slice(), + pub_key_y.as_slice(), + &index.to_be_bytes::<32>(), + ] + .concat(), + ); + + // address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:] + let data = [ + &[0xff], + factory.as_slice(), + salt.as_slice(), + init_code_hash.as_slice(), + ] + .concat(); + + Address::from_slice(&keccak256(data)[12..]) +} + +/// Derives a v2 address using the canonical protocol constants (TIP-1018). +/// +/// Convenience wrapper around [`derive_v2_address`] that uses the protocol-level +/// factory address and init code hash from `tempo_contracts`. +pub fn derive_v2_address_default(pub_key_x: &B256, pub_key_y: &B256) -> Address { + derive_v2_address( + pub_key_x, + pub_key_y, + U256::ZERO, + &tempo_contracts::CROSS_CHAIN_ACCOUNT_FACTORY_ADDRESS, + &tempo_contracts::CROSS_CHAIN_ACCOUNT_INIT_CODE_HASH, + ) +} + +/// Given a P256/WebAuthn signature's public key coordinates, recover the signer +/// address with support for both v1 (legacy) and v2 (CREATE2) derivation. +/// +/// If `from_hint` is provided (from the transaction's `from` field), it is used +/// to disambiguate between v1 and v2 addresses. If neither matches, returns an error. +/// If `from_hint` is `None`, defaults to v1 derivation (backward compatible). +pub fn recover_p256_address( + pub_key_x: &B256, + pub_key_y: &B256, + from_hint: Option
, +) -> Result { + let v1_addr = derive_p256_address(pub_key_x, pub_key_y); + + match from_hint { + Some(hint) if hint == v1_addr => Ok(v1_addr), + Some(hint) => { + let v2_addr = derive_v2_address_default(pub_key_x, pub_key_y); + if hint == v2_addr { + Ok(v2_addr) + } else { + Err(alloy_consensus::crypto::RecoveryError::new()) + } + } + // No hint: default to v1 for backward compatibility + None => Ok(v1_addr), + } +} + /// Verifies a P256 signature using the provided components /// /// This performs actual cryptographic verification of the P256 signature @@ -1648,4 +1739,156 @@ mod tests { let sig = TempoSignature::Keychain(KeychainSignature::new(Address::ZERO, inner)); assert!(sig.is_keychain()); } + + // ======================================================================== + // TIP-1018: v2 CREATE2 address derivation tests + // ======================================================================== + + #[test] + fn test_derive_v2_address_deterministic() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + let factory = Address::repeat_byte(0xAA); + let init_code_hash = B256::from([0xBB; 32]); + + let addr1 = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory, &init_code_hash); + let addr2 = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory, &init_code_hash); + + assert_eq!(addr1, addr2, "Same inputs must produce same address"); + } + + #[test] + fn test_derive_v2_address_differs_from_v1() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + let factory = Address::repeat_byte(0xAA); + let init_code_hash = B256::from([0xBB; 32]); + + let v1 = derive_p256_address(&pub_key_x, &pub_key_y); + let v2 = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory, &init_code_hash); + + assert_ne!(v1, v2, "v1 and v2 addresses must differ"); + } + + #[test] + fn test_derive_v2_address_different_index() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + let factory = Address::repeat_byte(0xAA); + let init_code_hash = B256::from([0xBB; 32]); + + let addr0 = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory, &init_code_hash); + let addr1 = derive_v2_address(&pub_key_x, &pub_key_y, U256::from(1), &factory, &init_code_hash); + let addr2 = derive_v2_address(&pub_key_x, &pub_key_y, U256::from(2), &factory, &init_code_hash); + + assert_ne!(addr0, addr1, "Different index must produce different address"); + assert_ne!(addr1, addr2, "Different index must produce different address"); + assert_ne!(addr0, addr2, "Different index must produce different address"); + } + + #[test] + fn test_derive_v2_address_different_factory() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + let init_code_hash = B256::from([0xBB; 32]); + + let factory_a = Address::repeat_byte(0xAA); + let factory_b = Address::repeat_byte(0xBB); + + let addr_a = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory_a, &init_code_hash); + let addr_b = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory_b, &init_code_hash); + + assert_ne!(addr_a, addr_b, "Different factory must produce different address"); + } + + #[test] + fn test_derive_v2_address_different_init_code_hash() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + let factory = Address::repeat_byte(0xAA); + + let hash_a = B256::from([0xBB; 32]); + let hash_b = B256::from([0xCC; 32]); + + let addr_a = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory, &hash_a); + let addr_b = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO, &factory, &hash_b); + + assert_ne!(addr_a, addr_b, "Different init code hash must produce different address"); + } + + #[test] + fn test_derive_v2_address_matches_create2_spec() { + // Manually compute CREATE2 to verify our implementation matches the spec: + // address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:] + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + let factory = Address::repeat_byte(0xAA); + let init_code_hash = B256::from([0xBB; 32]); + let index = U256::ZERO; + + // Compute salt manually + let salt = keccak256( + [ + pub_key_x.as_slice(), + pub_key_y.as_slice(), + &index.to_be_bytes::<32>(), + ] + .concat(), + ); + + // Compute CREATE2 address manually + let mut create2_input = Vec::with_capacity(1 + 20 + 32 + 32); + create2_input.push(0xff); + create2_input.extend_from_slice(factory.as_slice()); + create2_input.extend_from_slice(salt.as_slice()); + create2_input.extend_from_slice(init_code_hash.as_slice()); + let expected = Address::from_slice(&keccak256(&create2_input)[12..]); + + let actual = derive_v2_address(&pub_key_x, &pub_key_y, index, &factory, &init_code_hash); + assert_eq!(actual, expected, "Must match manual CREATE2 computation"); + } + + #[test] + fn test_recover_p256_address_v1_hint() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + + let v1_addr = derive_p256_address(&pub_key_x, &pub_key_y); + let result = recover_p256_address(&pub_key_x, &pub_key_y, Some(v1_addr)); + + assert_eq!(result.unwrap(), v1_addr, "Should return v1 address when hint matches v1"); + } + + #[test] + fn test_recover_p256_address_v2_hint() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + + let v2_addr = derive_v2_address_default(&pub_key_x, &pub_key_y); + let result = recover_p256_address(&pub_key_x, &pub_key_y, Some(v2_addr)); + + assert_eq!(result.unwrap(), v2_addr, "Should return v2 address when hint matches v2"); + } + + #[test] + fn test_recover_p256_address_bad_hint() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + + let bad_addr = Address::repeat_byte(0xFF); + let result = recover_p256_address(&pub_key_x, &pub_key_y, Some(bad_addr)); + + assert!(result.is_err(), "Should fail when hint matches neither v1 nor v2"); + } + + #[test] + fn test_recover_p256_address_no_hint_defaults_v1() { + let pub_key_x = B256::from([0x11; 32]); + let pub_key_y = B256::from([0x22; 32]); + + let v1_addr = derive_p256_address(&pub_key_x, &pub_key_y); + let result = recover_p256_address(&pub_key_x, &pub_key_y, None); + + assert_eq!(result.unwrap(), v1_addr, "No hint should default to v1 for backward compatibility"); + } } diff --git a/tips/tip-1018.md b/tips/tip-1018.md new file mode 100644 index 0000000000..40408648f8 --- /dev/null +++ b/tips/tip-1018.md @@ -0,0 +1,479 @@ +--- +id: TIP-1018 +title: Cross-Chain Deterministic Address Derivation +description: Modify P256/WebAuthn address derivation to use CREATE2-compatible addresses for cross-chain fund recovery. +authors: Georgios (@gakonst), Varun (@varun) +status: Draft +related: TIP-1000 +protocolVersion: TBD +--- + +# TIP-1018: Cross-Chain Deterministic Address Derivation + +## Abstract + +Tempo currently derives P256/WebAuthn account addresses as `keccak256(pubKeyX || pubKeyY)[12:]`. This derivation is Tempo-specific — there is no corresponding account on other EVM chains, so funds sent to a Tempo address on Base, Ethereum, or any other chain are permanently lost. + +This TIP introduces CREATE2-based deterministic address derivation for **new** P256/WebAuthn accounts, where the account address is the counterfactual CREATE2 address of a smart wallet deployable on any EVM chain. The same passkey produces the same address on every chain, and the user (or anyone) can deploy a recovery wallet at that address on any chain to access deposited funds. + +Existing accounts (v1) remain fully functional with no forced migration. An opt-in migration path is provided. + +## Motivation + +### The Problem + +Users see their Tempo wallet address (e.g., `0xfoo`) in the explorer, wallet settings, or any other surface. They copy it and send ETH/tokens to `0xfoo` on Base or Ethereum. Because the current derivation (`keccak256(pubKeyX || pubKeyY)[12:]`) has no meaning on other chains, those funds are irrecoverable. There is no private key, no contract, and no mechanism to access them. + +This is not a hypothetical risk. User behavior data from other wallet products (e.g., Coinbase) confirms that a significant percentage of users will attempt cross-chain sends regardless of warnings. The consequences are: + +1. **Permanent fund loss** for the user. +2. **Reputational damage** — public complaints on X, support burden. +3. **Legal exposure** — users may demand proof that Tempo cannot access the funds, triggering costly compliance processes. + +### Why UX Mitigations Are Insufficient + +Displaying addresses with a `tempo:` prefix or making them non-copy-pastable are useful mitigations but do not eliminate the problem: + +- Users screenshot addresses, type them manually, or use QR codes. +- Third-party integrations (exchanges, bridges, aggregators) may strip prefixes. +- Deposit flows on other chains don't enforce Tempo-specific formatting. + +The only robust solution is to make the address **actually recoverable** on other chains. + +### Alternatives Considered + +| Alternative | Why Rejected | +|-------------|-------------| +| **UX-only mitigations** (prefix, warnings) | Reduces frequency but doesn't eliminate loss. Users will still do it. | +| **Protocol-level address flip** (change all existing addresses) | Breaks all existing account state: balances, nonces, keychain, approvals, DEX positions. Catastrophic migration burden. | +| **Address aliasing** (protocol maps v1 ↔ v2) | Architecturally clean but massive scope — requires consensus + execution layer redesign, audits. | +| **CREATE3 pattern** | Eliminates initCode from address derivation, making the address depend only on factory + salt. However, this makes the address independent of wallet bytecode, so you can't verify what code will be deployed at an address before deployment. CREATE2 is more transparent and auditable. | + +### Design Decision: Versioned Accounts + +Rather than forcing a protocol-level identity change (which orphans all existing state), this TIP introduces **account versions**: + +- **v1 accounts** (existing): `address = keccak256(pubKeyX || pubKeyY)[12:]`. These remain fully functional on Tempo indefinitely. No migration required. +- **v2 accounts** (new): `address = CREATE2(factory, salt, initCodeHash)`. These are the default for new account creation and work identically on all EVM chains. + +Users with v1 accounts can optionally migrate to v2 via a guided flow. + +--- + +# Specification + +## 1. Address Derivation (v2) + +### Formula + +``` +salt = keccak256(abi.encodePacked(pubKeyX, pubKeyY, uint256(index))) +address = keccak256(0xff ++ factory ++ salt ++ keccak256(initCode))[12:] +``` + +Where: +- `pubKeyX`, `pubKeyY`: the P-256 public key coordinates (32 bytes each) of the user's passkey. +- `index`: a `uint256` account index (default `0`). Allows multiple wallets per passkey. +- `factory`: the canonical `CrossChainAccountFactory` address, deployed at the same address on all chains. +- `initCode`: the `creationCode` of `CrossChainAccount` (no constructor arguments). + +### Constants + +The following are **protocol constants** and MUST NOT change once deployed: + +| Constant | Value | Notes | +|----------|-------|-------| +| `FACTORY_ADDRESS` | TBD (deployed via deterministic deployer) | Identical on all chains | +| `INIT_CODE_HASH` | `keccak256(type(CrossChainAccount).creationCode)` | Frozen at a specific compiler version + settings | +| `COMPILER_VERSION` | `solc 0.8.28` | Locked to prevent initCode drift | +| `OPTIMIZER_RUNS` | `200` with `via_ir = true` | Locked to prevent initCode drift | + +### Counterfactual Address Computation + +The address can be computed **offchain** without any on-chain transaction: + +```solidity +function getAddress(bytes32 pubKeyX, bytes32 pubKeyY, uint256 index) public view returns (address) { + bytes32 salt = keccak256(abi.encodePacked(pubKeyX, pubKeyY, index)); + return address(uint160(uint256(keccak256(abi.encodePacked( + bytes1(0xff), + address(this), + salt, + INIT_CODE_HASH + ))))); +} +``` + +Or in Rust (for the Tempo node): + +```rust +fn derive_v2_address(pub_key_x: &B256, pub_key_y: &B256, index: U256) -> Address { + let salt = keccak256( + [pub_key_x.as_slice(), pub_key_y.as_slice(), &index.to_be_bytes::<32>()].concat() + ); + let data = [ + &[0xff], + FACTORY_ADDRESS.as_slice(), + salt.as_slice(), + INIT_CODE_HASH.as_slice(), + ].concat(); + Address::from_slice(&keccak256(data)[12..]) +} +``` + +### Account Index + +The `index` parameter (default `0`) allows a single passkey to control multiple independent wallets. This is useful for: + +- Separating personal and business funds. +- Isolating risky DeFi positions. +- Creating purpose-specific wallets without creating new passkeys. + +Each `(pubKeyX, pubKeyY, index)` tuple produces a distinct address. + +## 2. Protocol-Level Integration + +### Signature Recovery + +The `PrimitiveSignature::recover_signer()` function in `crates/primitives/src/transaction/tt_signature.rs` MUST be updated to support both v1 and v2 derivation. + +After the activation block: + +- **Existing accounts (v1)**: Transactions from v1 addresses continue to use the legacy derivation `keccak256(pubKeyX || pubKeyY)[12:]`. The protocol recognizes both derivation methods during signature recovery and uses the one that matches the transaction's `from` field. +- **New accounts (v2)**: New accounts created after the activation block default to v2 derivation. + +```rust +pub fn recover_signer_v2( + &self, + sig_hash: &B256, + from_hint: Option
, // the tx `from` field +) -> Result { + match self { + Self::P256(p256_sig) | Self::WebAuthn(webauthn_sig) => { + // ... verify signature as before ... + + let v1_addr = derive_p256_address(&pub_key_x, &pub_key_y); + let v2_addr = derive_v2_address(&pub_key_x, &pub_key_y, U256::ZERO); + + // If from_hint is provided, use it to disambiguate + match from_hint { + Some(hint) if hint == v1_addr => Ok(v1_addr), + Some(hint) if hint == v2_addr => Ok(v2_addr), + Some(_) => Err(RecoveryError::new()), // neither matches + None => Ok(v2_addr), // default to v2 for new accounts + } + } + Self::Secp256k1(sig) => { + // secp256k1 unchanged + alloy_consensus::crypto::secp256k1::recover_signer(sig, *sig_hash) + } + } +} +``` + +### AccountKeychain Precompile + +The AccountKeychain precompile (`keys[account][keyId]`) is unaffected for v1 accounts. For v2 accounts, the precompile uses the v2-derived address as the account key. + +No migration of keychain state is required because: +- v1 accounts keep their v1 keychain state. +- v2 accounts start with fresh keychain state (keys are enrolled during the first transaction or via the migration flow). + +### Nonces + +v2 accounts start with nonce `0`. The TIP-1000 account creation cost (250,000 gas) applies when the v2 account's nonce transitions from 0 to 1, identical to v1 behavior. + +v1 account nonces are unaffected. + +## 3. CrossChainAccount Smart Wallet + +The `CrossChainAccount` is a smart wallet contract deployed via CREATE2 on any EVM chain. It provides: + +1. **P-256 / WebAuthn signature verification** via Solady's pure-Solidity libraries (no chain-specific precompiles). +2. **ERC-1271 `isValidSignature`** for dapp/protocol integration. +3. **Multi-key support** (add/remove keys for rotation without changing address). +4. **EIP-712 typed data signing** with replay protection. + +### Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| **No constructor arguments** | Keeps `creationCode` identical across all chains → identical CREATE2 address. | +| **No proxy pattern** | Proxies require chain-specific implementation addresses, breaking cross-chain address identity. | +| **Pure Solidity P-256** | Uses Solady's `P256.sol` and `WebAuthn.sol` instead of Tempo's `AccountKeychain` precompile. This ensures the contract works on **any** EVM chain, not just Tempo. | +| **Atomic deploy + initialize** | Factory deploys and initializes in one call. Prevents front-running / griefing of uninitialized wallets. | +| **chainId in EIP-712 domain** | Prevents cross-chain signature replay (critical since the address is identical across chains). | +| **Account index in salt** | Supports multiple wallets per passkey without additional complexity. | + +### No Constructor Args — Why This Matters + +The wallet's `creationCode` must be byte-for-byte identical on every chain. If the wallet had constructor arguments (e.g., `constructor(bytes32 ownerX, bytes32 ownerY)`), the `initCode = creationCode ++ abi.encode(ownerX, ownerY)` would be personalized — but crucially, the salt already encodes the owner's identity, so embedding it again in initCode is redundant and increases deployment cost. + +Instead, ownership is established via a one-time `initialize()` call made atomically by the factory. + +### Interface + +```solidity +interface ICrossChainAccount { + // ---- Key Types ---- + enum KeyType { Secp256k1, P256, WebAuthnP256 } + + struct Key { + KeyType keyType; + uint40 expiry; // 0 = never expires + bytes publicKey; + } + + // ---- Initialization (factory only, one-time) ---- + function initialize(bytes32 ownerX, bytes32 ownerY) external; + + // ---- ERC-1271 ---- + function isValidSignature(bytes32 digest, bytes calldata signature) external view returns (bytes4); + + // ---- Execution ---- + function execute(address target, uint256 value, bytes calldata data, bytes calldata signature) external returns (bytes memory); + + // ---- Key Management (self-call only) ---- + function addKey(bytes32 keyHash, KeyType keyType, uint40 expiry, bytes calldata publicKey) external; + function removeKey(bytes32 keyHash) external; +} +``` + +### Initialization Security + +The `initialize()` function MUST: + +1. Revert if already initialized (`AlreadyInitialized` error). +2. Validate that `ownerX != 0 && ownerY != 0` (`InvalidKey` error). +3. Register the owner key in the `keys` mapping as `KeyType.WebAuthnP256` with `expiry = 0` (never expires). +4. Be called **atomically** by the factory in the same transaction as CREATE2 deployment. + +Because the factory atomically calls `initialize(ownerX, ownerY)` after CREATE2 deployment, an attacker cannot front-run initialization even if they observe the deployment transaction in the mempool — the factory either deploys-and-initializes in one atomic call, or reverts entirely. + +**Front-running the deployment itself** is not harmful because: +- The attacker would need to call `factory.createAccount(pubKeyX, pubKeyY)`, which initializes the wallet with the **legitimate owner's** pubkey. +- The attacker has no way to initialize it with a different key (factory enforces the salt ↔ pubkey binding). +- The attacker just pays gas for the user's deployment — effectively a gift. + +## 4. CrossChainAccountFactory + +### Interface + +```solidity +interface ICrossChainAccountFactory { + function createAccount(bytes32 pubKeyX, bytes32 pubKeyY) external returns (address); + function createAccount(bytes32 pubKeyX, bytes32 pubKeyY, uint256 index) external returns (address); + function getAddress(bytes32 pubKeyX, bytes32 pubKeyY) external view returns (address); + function getAddress(bytes32 pubKeyX, bytes32 pubKeyY, uint256 index) external view returns (address); +} +``` + +### Deployment + +The factory MUST be deployed at an identical address on all target chains. This is achieved by deploying via a well-known deterministic deployer (e.g., Nick's method at `0x4e59b44847b379578588920cA78FbF26c0B4956C`, which exists on all major EVM chains). + +### `createAccount` Behavior + +1. Compute `salt = keccak256(abi.encodePacked(pubKeyX, pubKeyY, index))`. +2. Attempt `CREATE2` deployment of `CrossChainAccount` with computed salt. +3. If account already exists at the computed address, return the existing address (idempotent). +4. If newly deployed, call `account.initialize(pubKeyX, pubKeyY)` atomically. +5. Return the account address. + +### Factory Bytecode Stability + +The factory contract MUST be compiled with a frozen compiler configuration (see Constants above). Any change to the wallet's `creationCode` produces a different `INIT_CODE_HASH` and therefore different addresses — this would break the core invariant. + +If a new wallet version is needed (e.g., security fix), it MUST be deployed as a **new factory** with a new `INIT_CODE_HASH`, producing a new set of addresses. Old wallets remain functional. + +## 5. Cross-Chain Signature Replay Protection + +Because the wallet exists at the same address on all chains, signature replay is a critical concern. + +### EIP-712 Domain Separator + +The wallet's EIP-712 domain MUST include `chainId`: + +```solidity +function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ("CrossChainAccount", "1"); +} +// Solady's EIP712 automatically includes chainId and verifyingContract in the domain separator. +``` + +This ensures: +- A signature valid on Ethereum cannot be replayed on Base (different chainId). +- A signature valid on Base cannot be replayed on Tempo (different chainId). + +### Nonce-Based Replay Protection + +Each wallet maintains an independent `nonce` per chain. The `execute()` function includes the nonce in the EIP-712 struct hash: + +```solidity +bytes32 structHash = keccak256(abi.encode( + EXECUTE_TYPEHASH, + target, + value, + keccak256(data), + nonce +)); +``` + +## 6. Migration Path (v1 → v2, Opt-In) + +### For Users + +1. User opens wallet settings → "Upgrade to Universal Address". +2. App computes the v2 address from the user's passkey coordinates. +3. App displays both addresses and explains the migration. +4. User initiates migration: + a. Deploy CrossChainAccount on Tempo (via factory). + b. Transfer native balance from v1 address to v2 address. + c. Transfer TIP-20 tokens from v1 to v2. + d. Re-enroll secondary keys on the v2 account via AccountKeychain. +5. App switches the displayed address to v2. +6. v1 address remains functional — user can still access any remaining assets. + +### What Migrates Automatically + +- Nothing. This is an explicit asset transfer, not a state migration. + +### What Requires Manual Action + +| State | Migration Action | +|-------|-----------------| +| Native balance | Transfer from v1 to v2 | +| TIP-20 balances | Transfer from v1 to v2 | +| Secondary keys (AccountKeychain) | Re-enroll on v2 account | +| 2D nonces | Reset (v2 starts fresh) | +| DEX positions | Close on v1, reopen on v2 | +| Approvals / allowances | Re-approve from v2 | + +### Migration Tooling + +The wallet app SHOULD provide a one-click migration flow that batches these operations into a single multicall transaction where possible. + +## 7. Activation + +### Tempo Protocol + +- The v2 derivation is activated at a specific block height (configured in chainspec). +- Before activation: all P256/WebAuthn accounts use v1 derivation. +- After activation: new accounts default to v2 derivation. Existing v1 accounts continue to work. +- The protocol MUST support both v1 and v2 derivation indefinitely (no sunset). + +### Other EVM Chains + +- Deploy the `CrossChainAccountFactory` at the canonical address on each target chain. +- No protocol changes needed on other chains — the factory and wallet are standard EVM contracts. + +--- + +# Invariants + +## Core Invariants + +1. **Address determinism**: For a given `(pubKeyX, pubKeyY, index)`, the v2 address MUST be identical across all EVM chains, at all times, regardless of deployment state. + +2. **Deployment idempotency**: Calling `createAccount(pubKeyX, pubKeyY, index)` multiple times MUST always return the same address and MUST NOT revert (except for out-of-gas). + +3. **Initialization atomicity**: A deployed `CrossChainAccount` MUST be initialized in the same transaction as deployment. There MUST NOT exist a window where the wallet is deployed but uninitialized. + +4. **Owner integrity**: After initialization, `ownerX` and `ownerY` MUST equal the pubkey coordinates used to compute the CREATE2 salt. No other values are possible. + +5. **Cross-chain replay prevention**: A valid signature on chain A MUST NOT be valid on chain B (enforced via chainId in EIP-712 domain separator). + +6. **v1 backward compatibility**: Existing v1 accounts MUST remain fully functional after TIP-1018 activation. No state migration is forced. No v1 functionality is removed. + +7. **InitCode stability**: The `INIT_CODE_HASH` MUST NOT change. Any wallet upgrade requires a new factory and new address space. + +## Critical Test Cases + +### Address Derivation + +1. Same `(pubKeyX, pubKeyY, 0)` produces identical address from `getAddress()` and from manual CREATE2 computation. +2. Different `index` values produce different addresses. +3. Different pubkeys produce different addresses. +4. `getAddress()` returns the same value before and after `createAccount()`. + +### Deployment & Initialization + +5. `createAccount()` deploys and initializes atomically — wallet is usable immediately. +6. Second `createAccount()` call with same params returns existing address (no revert, no redeployment). +7. Direct call to `initialize()` on an already-initialized wallet reverts with `AlreadyInitialized`. +8. `initialize()` with zero pubkey coordinates reverts with `InvalidKey`. + +### Signature Verification + +9. Valid WebAuthn signature from owner key passes `isValidSignature()`. +10. Valid P-256 signature from owner key passes `isValidSignature()`. +11. Signature from non-owner key fails `isValidSignature()`. +12. Signature from owner key on chain A fails on chain B (cross-chain replay rejected). +13. Replayed `execute()` call with used nonce reverts. + +### Key Management + +14. Owner can add a secondary key via `execute()` → `addKey()`. +15. Owner can remove a secondary key via `execute()` → `removeKey()`. +16. Cannot remove the primary owner key. +17. Expired key fails signature verification. +18. Non-self call to `addKey()` / `removeKey()` reverts with `NotAuthorized`. + +### Protocol Integration (Tempo-specific) + +19. Transaction from v1 address (post-activation) is accepted and processed normally. +20. Transaction from v2 address (post-activation) is accepted and processed normally. +21. Transaction with ambiguous signature (matching both v1 and v2) is resolved via `from` field. +22. AccountKeychain state for v1 accounts is unaffected by activation. +23. v2 account's first transaction charges TIP-1000's 250,000 gas account creation cost. + +### Edge Cases + +24. Attacker front-runs deployment on chain B → wallet is initialized with correct owner (attacker just pays gas). +25. Factory deployed at different address on chain B → `getAddress()` returns different address (expected; factory address mismatch is detectable). +26. Multiple wallets per passkey (index 0, 1, 2, ...) all function independently. +27. Key rotation (add new key, remove old key) does not change the wallet address. +28. Wallet with all keys removed/expired cannot execute transactions (locked out — expected). + +--- + +# Security Considerations + +## Tanishk's Concern: "Abuse as Default" + +> "Don't want people to abuse this recovery system as the default, and then they start assuming that tempo will also have the recovery bytecode deployed to the address." + +This is a valid concern. On Tempo, the v2 address is a **protocol-level account** — it does not need the `CrossChainAccount` contract deployed. Users might assume the contract is deployed on Tempo too, and try to interact with it as a smart contract. + +**Mitigation**: +- The Tempo wallet UI MUST clearly communicate that the address is a native Tempo account, not a smart contract on Tempo. +- The `CrossChainAccountFactory` on Tempo should still be deployable (for consistency), but the Tempo node processes transactions from v2 addresses via native signature verification, not via ERC-1271. +- Documentation MUST explain that fund recovery on other chains requires deploying the wallet contract on that chain. + +## Factory / InitCode Drift + +If the factory or wallet bytecode is compiled differently on different chains (different compiler version, optimizer settings, linked libraries), the addresses will silently diverge. This is the most dangerous failure mode because it appears to work in testing but fails in production. + +**Mitigation**: +- Freeze compiler configuration as protocol constants. +- Publish the verified bytecode and initCodeHash as part of this TIP. +- Verification tooling MUST check that deployed factory + wallet bytecode matches the canonical hash on each chain. + +## EIP-7702 Interaction + +EIP-7702 allows EOAs to temporarily delegate to contract code. If a v2 address (which is an EOA on Tempo but a contract on other chains) uses 7702 delegation: +- On Tempo: 7702 delegation must include chainId to prevent cross-chain replay. +- On other chains: the address already has code (the CrossChainAccount), so 7702 delegation may interact unexpectedly. + +**Mitigation**: 7702 delegation authorizations MUST include chainId. The wallet contract SHOULD reject 7702 delegation attempts (or handle them gracefully) on chains where it is deployed as a contract. + +## Key Compromise Recovery + +If the primary passkey is compromised: +- The attacker can sign transactions from the wallet on any chain where it's deployed. +- Key rotation requires signing with an authorized key — if only the compromised key exists, the user is locked out of rotation. + +**Mitigation**: +- Encourage users to enroll multiple keys (e.g., backup passkey, hardware key). +- Consider adding time-delayed recovery mechanisms in future wallet versions. +- Social recovery is out of scope for this TIP but could be layered on via key management.