diff --git a/docs/protocol/smart-wallet-signature-verification.md b/docs/protocol/smart-wallet-signature-verification.md new file mode 100644 index 000000000..356c675e9 --- /dev/null +++ b/docs/protocol/smart-wallet-signature-verification.md @@ -0,0 +1,216 @@ +# How Smart Wallet Signature Verification Works + +> Companion to [`smart-wallet-signatures.md`](./smart-wallet-signatures.md), which +> documents the signature *format*. This document explains the verification *flow* +> and the **security guarantees** it provides for each signer type. + +--- + +## 1. Three signer types, three verification paths + +Pyaleph's `EVMVerifier` handles three very different kinds of signers behind the same +`verify_signature` entry point: + +| Signer type | Signature shape | RPC calls | Authority | +|---|---|---|---| +| **EOA** (MetaMask, Ledger, …) | 65-byte ECDSA | 0 | Private key (eternal) | +| **Deployed smart wallet** (ERC-1271) | Any bytes the contract accepts | 2 (`get_code` + `isValidSignature`) | Current contract state | +| **Counterfactual smart wallet** (ERC-6492) | `abi.encode(factory, calldata, innerSig) + 0x6492…6492` | 1 (`UniversalSigValidator`) | Factory calldata (frozen at CREATE2 time) | + +Detection is upfront and cheap: if the signature ends with the ERC-6492 magic suffix, +route to the counterfactual path. Otherwise try plain ECDSA; if that fails and the +sender has deployed bytecode, route to ERC-1271. + +--- + +## 2. Counterfactual verification (before deployment) + +### Mechanical flow in pyaleph + +``` +1. Detect 0x6492…6492 suffix → present +2. Build deploy_data = ValidateSigOffchainBytecode + abi.encode( + sender, messageHash, fullSignature + ) +3. eth_call({"data": deploy_data}) # no `to` field = contract creation +4. Check the single returned byte == 0x01 +``` + +ERC-6492 uses the **contract-creation pattern** — there is no pre-deployed +validator contract on chain. The bytecode is sent as creation data; it runs as +a constructor and performs two operations atomically: + +1. **Simulates deployment**: `factory.call(factoryCalldata)` — this deploys the + wallet contract at some address, purely inside the simulation. No state changes + persist because we use `eth_call`, not `sendTransaction`. +2. **Calls `isValidSignature`**: routes the inner signature through the deployed + contract's verification logic, and returns a single byte (`0x01` valid / + `0x00` invalid) from the constructor's RETURN opcode. + +The canonical bytecode is the EIP-6492 reference implementation from +AmbireTech/signature-validator, shipped at +`src/aleph/chains/assets/erc6492_validator_bytecode.hex`. + +### What the simulation does (Kernel example) + +After the factory call, a Kernel wallet exists (inside the simulation) at some +CREATE2 address. Its `isValidSignature`: + +1. Parses the 86-byte inner sig → `(type=0x01, validator=0x845A…4cE57, ecdsa)` +2. Dispatches to the ECDSA validator plugin configured as **root validator** +3. The plugin wraps the Aleph hash in its own EIP-712 domain (with `block.chainid` + and wallet address) +4. Runs `ecrecover` on the wrapped hash with the 65-byte ECDSA +5. Compares the recovered address to the **owner baked into `factoryCalldata`** +6. Returns the ERC-1271 magic value `0x1626ba7e` on match + +### Security guarantees + +1. **The Aleph hash binds to the message.** + The hash is `keccak256(EIP-191(chain + sender + type + item_hash))`. Any + tampering with sender, item content, or chain invalidates the signature. + +2. **The sender address is cryptographically bound to the wallet's config.** + `sender = keccak256(0xff || factory || salt || keccak256(initcode))[12:]`. + The initcode embeds the owner (via `factoryCalldata`). Swapping the owner + changes the initcode → changes the CREATE2 address → no longer equals + `sender`. Finding a colliding `(factory, salt, initcode)` tuple is a 160-bit + preimage search — computationally infeasible. + +3. **Only the owner's private key could produce the inner ECDSA.** + The ECDSA validator recovers a key and compares it against the configured + owner. Without that private key, forging the inner sig would require + breaking secp256k1. + +### Attacks defeated + +| Attack | Why it fails | +|---|---| +| Submit ERC-6492 with a malicious factory | Simulated deployment lands at a different address → subsequent `isValidSignature` call hits empty bytecode → invalid | +| Swap the owner inside `factoryCalldata` | Changes the initcode → changes the CREATE2 address → no longer matches `sender` | +| Replay signature on a different Aleph message | Hash depends on `item_hash`, `sender`, `chain`, `type`; any change invalidates | +| Replay across chains | Kernel's EIP-712 domain includes `block.chainid` (also why pyaleph is scoped to ETH mainnet only until per-chain RPCs are wired) | + +### Residual trust assumptions + +- Anyone who obtains the owner's private key can sign as `sender` — same risk as + any EOA. +- Trust in the `ValidateSigOffchain` / `UniversalSigValidator` bytecode. The + reference implementation is documented in EIP-6492 and sourced from + AmbireTech/signature-validator. Worth verifying the packaged hex against the + upstream source before relying on it in production. + +--- + +## 3. Deployed-wallet verification (ERC-1271, the normal case) + +Once the smart wallet has been deployed (first on-chain transaction), clients stop +sending the ERC-6492 wrapper. They send only the inner signature (86 bytes for +Kernel) directly. + +### Mechanical flow in pyaleph + +``` +1. Detect 0x6492…6492 suffix → NOT present +2. Try plain ECDSA ecrecover → fails (sender is a contract, not an EOA) +3. eth_call → w3.eth.get_code(sender) → non-empty = deployed +4. eth_call → sender.isValidSignature(hash, innerSig) +5. Check return == 0x1626ba7e +``` + +No factory, no simulation, no counterfactual reasoning. We query the actual +on-chain contract at `sender`, and the contract itself is the authority on whether +the signature is valid. + +### What the contract does internally (Kernel example) + +Same as step 2→6 of the counterfactual flow above, but reading state from the +actual deployed contract instead of a simulated one: + +1. Parses 86-byte sig → `(type, validator, ecdsa)` +2. Routes to the **currently configured** root validator plugin +3. The plugin wraps the Aleph hash in EIP-712 +4. `ecrecover` on the wrapped hash +5. Compares recovered address to the **currently configured owner** +6. Returns `0x1626ba7e` if match + +### Crucial difference vs EOA / counterfactual + +Smart wallets have **mutable authorization**: + +- **Key rotation:** if the wallet rotates from Key-A to Key-B after signing, most + validators (including Kernel's ECDSA validator) will reject the old signature + on re-verification. Signatures effectively expire. +- **Multi-validator setups:** a wallet can later add session keys, passkeys, or + multisig plugins. Any of those authorized signers can also produce valid sigs + as `sender` — not just the original EOA. +- **Upgradeable logic:** if the contract is upgradeable, a malicious upgrade + could flip `isValidSignature` to always return true. Trust boundary = whoever + controls the upgrade keys. + +Contrast with EOAs: an ECDSA signature produced once is valid forever against the +same address, because the "address" is literally `keccak256(pubkey)[12:]` — there +is no state to mutate. + +### Security guarantees + +- ✅ The contract at `sender` **right now** approves this signature for this hash. +- ✅ Whoever holds currently-authorized keys signed this message. +- ✅ No address collision: the contract is queried directly, not reconstructed. +- ⚠️ "Owner at signing time" may differ from "owner at verification time". + +### Attacks defeated + +Same list as counterfactual, minus the factory-related ones (no factory involved). +Replay across chains is mitigated by ETH-mainnet-only scoping. + +--- + +## 4. Applied to the real example + +For Aleph message +[`6f699b25…1a26d`](https://api2.aleph.im/api/v0/messages/6f699b252db10e65e8651a77289a7789e2da77ce26c4f4ad247fbe9bd1e1a26d): + +``` +Aleph sender: 0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635 (Kernel smart wallet, counterfactual) +Owner EOA: 0xfFFEfCDE25e1d00474530f1A7b90D02CEda94fD7 (private key holder) +Factory: 0xd703aae79538628d27099b8c4f621be4ccd142d5 +Validator: 0x845ADb2C711129d4f3966735eD98a9F09fC4cE57 +``` + +The message arrives with the ERC-6492 wrapper because the wallet isn't deployed +yet. Pyaleph: + +1. Detects the `0x6492…6492` suffix → routes to `UniversalSigValidator`. +2. `eth_call` to `0x0000…3823` simulates deploying a Kernel at `0xa9F3…1635` + configured with owner `0xfFFE…4fD7`. +3. Calls `isValidSignature` on that simulated contract with the 86-byte inner + sig. +4. The ECDSA validator recovers an address from the EIP-712-wrapped hash and + compares it to `0xfFFE…4fD7`. +5. If it matches, returns `0x1626ba7e` → pyaleph accepts the message as signed by + `0xa9F3…1635`. + +At some later point, the user performs their first on-chain transaction and the +wallet is deployed. From that message onward, Privy sends only the 86-byte inner +sig (no wrapper). Pyaleph's `EVMVerifier` then routes through the ERC-1271 path +instead — same guarantees, fewer bytes, one fewer simulated step. + +--- + +## 5. What the verification does NOT guarantee + +Worth spelling out, so we don't over-promise: + +- **Liveness of the owner's key.** If the key is lost or stolen, the attacker can + sign as `sender`. This is identical to any EOA risk. +- **Correctness of contract logic.** We trust that the Kernel contract and its + plugins implement `isValidSignature` faithfully. A buggy or malicious wallet + implementation could accept arbitrary signatures. +- **Immutability over time for deployed wallets.** A signature that verified + today may not verify tomorrow if the wallet's config changes. Pyaleph's model + is "valid at time of verification", not "valid forever". +- **Cross-chain uniqueness.** A signature produced for a wallet on Base is not + validatable on Ethereum mainnet because the EIP-712 domain uses `block.chainid`. + This is why pyaleph currently only enables smart-wallet verification on + `Chain.ETH`. diff --git a/docs/protocol/smart-wallet-signatures.md b/docs/protocol/smart-wallet-signatures.md new file mode 100644 index 000000000..6574c771b --- /dev/null +++ b/docs/protocol/smart-wallet-signatures.md @@ -0,0 +1,329 @@ +# ERC-6492 / ERC-1271 Smart Wallet Signatures in Aleph + +> Technical reference for smart contract wallet signature verification, generated from +> analysis of the real Aleph message +> `6f699b252db10e65e8651a77289a7789e2da77ce26c4f4ad247fbe9bd1e1a26d`. + +--- + +## 1. Context: why these signatures appear + +Providers such as **Privy** create **smart account** wallets (ZeroDev Kernel) for +users. These contracts can exist **counterfactually** — the address is deterministic +(CREATE2), but the bytecode is not on chain until someone pays the gas for the first +deployment. + +When a user signs an Aleph message with Privy before their wallet has been deployed: + +- Privy produces an **ERC-6492** signature that bundles the deployment instructions + for the wallet together with the inner ECDSA signature. +- Before this change, pyaleph did not understand this format and rejected the signature + as invalid. + +--- + +## 2. Contract stack involved (real-world example) + +| Address | Role | +|---|---| +| `0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635` | **Kernel smart wallet** (Aleph sender). Not yet deployed. | +| `0xd703aae79538628d27099b8c4f621be4ccd142d5` | **Factory** — deploys the smart wallet via `createAccount()` | +| `0xaac5d4240af87249b3f71bc8e4a2cae074a3e419` | **Kernel implementation** — the logic singleton | +| `0x845ADb2C711129d4f3966735eD98a9F09fC4cE57` | **ECDSA Validator plugin** — verifies that the owner signed | +| `0xfFFEfCDE25e1d00474530f1A7b90D02CEda94fD7` | **Owner EOA** — the private key that actually produces the ECDSA signature | + +--- + +## 3. Full schema of an ERC-6492 signature + +``` +FULL SIGNATURE (hex) +│ +├── ABI-encoded(address factory, bytes calldata, bytes innerSig) +│ │ +│ ├── [0:32] factory = 0xd703aae79538628d27099b8c4f621be4ccd142d5 (padded) +│ ├── [32:64] offset of calldata = 0x60 (96) +│ ├── [64:96] offset of innerSig = 0x260 (608) +│ │ +│ ├── CALLDATA (452 bytes) — instructions for the factory +│ │ ├── selector: 0xc5265d5d → createAccount(address impl, bytes initData, uint256 index) +│ │ ├── impl: 0xaac5d4240af87249b3f71bc8e4a2cae074a3e419 +│ │ ├── index: 0 +│ │ └── initData (Kernel initialize, 292 bytes): +│ │ ├── selector: 0x3c3b752b → initialize(...) +│ │ ├── validator type: 0x01 (root ECDSA validator) +│ │ ├── validator plugin: 0x845ADb2C711129d4f3966735eD98a9F09fC4cE57 +│ │ └── owner EOA: 0xfFFEfCDE25e1d00474530f1A7b90D02CEda94fD7 ← real private key +│ │ +│ └── INNER SIGNATURE (86 bytes) — signature produced by the owner EOA +│ ├── [0] type byte: 0x01 (root validator) +│ ├── [1:21] validator: 0x845ADb2C711129d4f3966735eD98a9F09fC4cE57 +│ └── [21:86] ECDSA (65 bytes): +│ ├── r: 0x94f8df9bcc3e2fa2049519666e9977ff76f9c99322db6a1f1117f3955411b2ae +│ ├── s: 0x316b72e49bd1743a5dee905ea4f27c4e7912479995f6b99eb56c44349dabe373 +│ └── v: 28 (0x1c) +│ +└── MAGIC SUFFIX (32 bytes): 0x6492649264926492649264926492649264926492649264926492649264926492 +``` + +### Full schema of an ERC-1271 signature (same wallet, after deployment) + +Once the smart wallet has been deployed on chain, the ERC-6492 wrapper, the factory +calldata and the magic suffix are all dropped. The client sends only the inner +signature — the wallet itself is now queryable on chain, so there's nothing to +"teach" the verifier about how to reconstruct it. + +For the same Kernel wallet (`0xa9F3…1635`, same owner), the post-deployment +signature is just 86 bytes: + +``` +INNER SIGNATURE (86 bytes) — sent as-is, no wrapper, no suffix +│ +├── [0] type byte: 0x01 +│ └── Kernel signature type selector (routes to the root validator) +│ +├── [1:21] validator: 0x845ADb2C711129d4f3966735eD98a9F09fC4cE57 +│ └── Address of the ECDSA validator plugin baked into this wallet +│ +└── [21:86] ECDSA (65 bytes) — produced by the owner EOA's private key + ├── [21:53] r: 0x94f8df9bcc3e2fa2049519666e9977ff76f9c99322db6a1f1117f3955411b2ae + ├── [53:85] s: 0x316b72e49bd1743a5dee905ea4f27c4e7912479995f6b99eb56c44349dabe373 + └── [85] v: 28 (0x1c) +``` + +Full hex (exact bytes sent over the wire): + +``` +0x01 ← type byte + 845adb2c711129d4f3966735ed98a9f09fc4ce57 ← validator (20 bytes) + 94f8df9bcc3e2fa2049519666e9977ff76f9c99322db6a1f1117f3955411b2ae ← r (32 bytes) + 316b72e49bd1743a5dee905ea4f27c4e7912479995f6b99eb56c44349dabe373 ← s (32 bytes) + 1c ← v (1 byte) +``` + +Flattened: + +``` +0x01845adb2c711129d4f3966735ed98a9f09fc4ce5794f8df9bcc3e2fa2049519666e9977ff76f9c99322db6a1f1117f3955411b2ae316b72e49bd1743a5dee905ea4f27c4e7912479995f6b99eb56c44349dabe3731c +``` + +**Size comparison:** + +| Signature type | Size | Overhead vs bare ECDSA | +|---|---|---| +| Bare ECDSA (plain EOA) | 65 bytes | — | +| ERC-1271 (deployed Kernel) | 86 bytes | +21 bytes (type + validator) | +| ERC-6492 (counterfactual Kernel) | ~800 bytes | +~735 bytes (factory, calldata, wrapper, magic) | + +**What's NOT in the ERC-1271 payload** (but is in ERC-6492): + +- No factory address — the wallet is already on chain, no need to redeploy. +- No `initData` / owner EOA — stored inside the deployed contract, queried directly + by `isValidSignature`. +- No `0x6492…6492` magic suffix — the verifier doesn't need to know "please simulate + a deployment"; it just calls the live contract. + +The inner signature byte-layout is identical to the one inside the ERC-6492 +wrapper. The client literally reuses the same `innerSig` bytes; it just stops +wrapping them once there's deployed code to talk to. + +--- + +## 4. The message being signed in Aleph + +Pyaleph builds the verification buffer in `src/aleph/chains/common.py`: + +```python +buffer = f"{message.chain.value}\n{message.sender}\n{message.type.value}\n{message.item_hash}" +# Real example: +# "ETH\n0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635\nPOST\n6f699b252db..." +``` + +It then wraps the buffer with the EIP-191 personal sign prefix in +`src/aleph/chains/evm.py`: + +```python +message_hash = encode_defunct(text=verification.decode("utf-8")) +# Equivalent to eth_sign(buffer) = keccak256("\x19Ethereum Signed Message:\n" + len + buffer) +``` + +**Important:** the owner EOA (`0xfFFE…`) does **not** sign this hash directly. Kernel +wraps it in its own EIP-712 hash before presenting it to the EOA. This is why a direct +`ecrecover` over the inner ECDSA bytes returns `0x2eA6…` (an unrelated address) instead +of the owner `0xfFFE…`. The only correct verification path is to call the contract +itself (`isValidSignature`), which performs that wrapping internally. + +--- + +## 5. Why signatures used to fail (two reasons) + +### Reason A — ERC-6492 was not detected + +`EVMVerifier.verify_signature` called `Account.recover_message(hash, signature)` on the +full 800+ byte blob. `eth-account` either raised or returned a meaningless address. + +### Reason B — the owner does not sign the Aleph hash directly + +Kernel wraps the message with its own EIP-712 domain before asking the owner to sign. +The only correct way to verify this is to call the contract (`isValidSignature`), +which performs the wrapping internally. + +--- + +## 6. Example signature BEFORE deployment (ERC-6492) + +The wallet `0xa9F3…1635` is NOT yet on chain. The signature carries the full ERC-6492 +wrapper: + +``` +0x +# ABI-encoded (factory, calldata, innerSig): +000000000000000000000000d703aae79538628d27099b8c4f621be4ccd142d5 ← factory +0000000000000000000000000000000000000000000000000000000000000060 ← calldata offset +0000000000000000000000000000000000000000000000000000000000000260 ← innerSig offset +...452 bytes of factory calldata (createAccount + initialize)... +...86 bytes of inner signature (type + validator + r+s+v)... +6492649264926492649264926492649264926492649264926492649264926492 ← MAGIC (32 bytes) +``` + +**Validation requires:** simulate wallet deployment → call `isValidSignature`. + +--- + +## 7. Example signature AFTER deployment (ERC-1271) + +Once the wallet `0xa9F3…1635` has been deployed, Privy produces a signature without the +ERC-6492 wrapper. Only the 86 bytes of the inner signature are sent: + +``` +0x +01 ← type byte (root validator) +845adb2c711129d4f3966735ed98a9f09fc4ce57 ← validator plugin (20 bytes) +94f8df9bcc3e2fa2049519666e9977ff76f9c993 ← ECDSA r (partial, 32 bytes) +... +1c ← ECDSA v +``` + +Full hex example (86 bytes): + +``` +0x01845adb2c711129d4f3966735ed98a9f09fc4ce5794f8df9bcc3e2fa2049519666e9977ff76f9c99322db6a1f1117f3955411b2ae316b72e49bd1743a5dee905ea4f27c4e7912479995f6b99eb56c44349dabe3731c +``` + +**Validation:** call `isValidSignature(hash, sig)` directly on the deployed contract. + +--- + +## 8. Correct validation flow + +``` +┌─────────────────────────────────────────────────────────┐ +│ Does the signature end with 0x6492…6492 (ERC-6492)? │ +└─────────────────────────────────────────────────────────┘ + │ Yes │ No + ▼ ▼ +┌─────────────────────────┐ ┌────────────────────────────┐ +│ ERC-6492 (counterfactual)│ │ Is len(sig) == 65 bytes? │ +│ Decode (factory, │ └────────────────────────────┘ +│ calldata, innerSig) │ │ Yes │ No +│ Call UniversalSig │ ▼ ▼ +│ Validator via eth_call │ ┌──────────────┐ ┌──────────────┐ +│ → isValidSignature? │ │ Plain ECDSA │ │ ERC-1271 │ +└─────────────────────────┘ │ ecrecover │ │ eth_call │ + ▼ │ == sender? │ │ isValidSig │ + valid/invalid └──────────────┘ └──────────────┘ +``` + +**Cost in RPC calls:** + +- Plain ECDSA: 0 calls (cheapest) +- ERC-1271 deployed: 1 `eth_call` +- ERC-6492 counterfactual: 1 `eth_call` (via `UniversalSigValidator`) + +--- + +## 9. ERC-1271 `isValidSignature` + +Selector: `0x1626ba7e` + +```python +from eth_abi.abi import encode + +VALID_SIG_MAGIC = bytes.fromhex("1626ba7e") +IS_VALID_SIG_SELECTOR = bytes.fromhex("1626ba7e") + +calldata = IS_VALID_SIG_SELECTOR + encode( + ["bytes32", "bytes"], [message_hash, signature] +) + +result = await w3.eth.call({"to": sender_address, "data": calldata}) +is_valid = result[:4] == VALID_SIG_MAGIC +``` + +--- + +## 10. ERC-6492 validation via contract-creation bytecode + +ERC-6492 does **not** rely on a pre-deployed contract. Instead it uses a clever +`eth_call` trick: send the `ValidateSigOffchain` deployer bytecode as +contract-creation data (no `to` field). The bytecode runs as a constructor, deploys +a `UniversalSigValidator` inline, simulates the factory deployment, calls +`isValidSignature`, and returns 1 byte: `0x01` if valid, `0x00` if invalid — all +inside a single `eth_call`, with no persisted state changes. + +The canonical bytecode is the reference implementation from +[AmbireTech/signature-validator](https://github.com/AmbireTech/signature-validator) +(see also [EIP-6492](https://eips.ethereum.org/EIPS/eip-6492)). Pyaleph ships it +as an asset file at `src/aleph/chains/assets/erc6492_validator_bytecode.hex`. + +```python +from eth_abi.abi import encode + +constructor_args = encode( + ["address", "bytes32", "bytes"], + [sender_address, message_hash, full_erc6492_signature], +) + +deploy_data = UNIVERSAL_VALIDATOR_BYTECODE + constructor_args + +# No `to` field = contract creation; the bytecode runs as a constructor. +result = await w3.eth.call({"data": "0x" + deploy_data.hex()}) + +# Returns a single byte: 0x01 valid / 0x00 invalid +is_valid = result == b"\x01" +``` + +--- + +## 11. Current scope: Ethereum mainnet only + +Smart wallet verification (ERC-1271 and ERC-6492) is intentionally limited to +`Chain.ETH` for now. + +**Why:** smart wallets use `block.chainid` in their EIP-712 domain separator, so a +signature produced on (say) Base cannot be validated by calling `isValidSignature` +against an Ethereum mainnet RPC — the domains won't match and the call will return +invalid even when the signature is genuine. Until pyaleph has per-chain RPC URLs +wired through, other EVM chains keep their previous behavior: plain ECDSA only. + +| Chain | Verifier | Plain ECDSA | ERC-1271 | ERC-6492 | +|---|---|---|---|---| +| `ETH` | `EthereumVerifier(rpc_url=...)` | ✅ | ✅ | ✅ | +| `ETHERLINK` | `EthereumVerifier()` (no RPC) | ✅ | ❌ (skipped) | ❌ (skipped) | +| All other EVM chains (Base, Arbitrum, Optimism, …) | `EVMVerifier()` (no RPC) | ✅ | ❌ (skipped) | ❌ (skipped) | + +Plain EOA messages on every EVM chain continue to work exactly as before — the +scoping above affects only smart contract wallet signatures. + +--- + +## 12. Files changed in pyaleph + +| File | Change | +|---|---| +| `src/aleph/chains/evm.py` | Add ERC-6492 / ERC-1271 detection paths + RPC client | +| `src/aleph/chains/signature_verifier.py` | Pass `rpc_url` to `EVMVerifier` | +| `src/aleph/api_entrypoint.py` | Pass `config.ethereum.api_url.value` to `SignatureVerifier` | +| `src/aleph/jobs/process_pending_messages.py` | Same | +| `src/aleph/jobs/fetch_pending_messages.py` | Same | +| `tests/chains/test_evm.py` | Tests for ERC-1271 and ERC-6492 paths | diff --git a/pyproject.toml b/pyproject.toml index 21f216da8..b13ef80ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -218,13 +218,16 @@ keep_full_version = true [tool.pytest.ini_options] minversion = "6.0" pythonpath = [ "src" ] -addopts = "-vv -m \"not ledger_hardware\"" +addopts = "-vv -m \"not ledger_hardware and not network\"" filterwarnings = [ "ignore:pkg_resources is deprecated as an API:UserWarning", ] norecursedirs = [ "*.egg", "dist", "build", ".tox", ".venv", "*/site-packages/*", ".claude", ".worktrees" ] testpaths = [ "tests/unit" ] -markers = { ledger_hardware = "marks tests as requiring ledger hardware" } +markers = [ + "ledger_hardware: marks tests as requiring ledger hardware", + "network: marks tests as requiring external network access (e.g. Ethereum mainnet RPC)", +] [tool.coverage.run] branch = true diff --git a/src/aleph/api_entrypoint.py b/src/aleph/api_entrypoint.py index b4d0428ed..aa0a6659f 100644 --- a/src/aleph/api_entrypoint.py +++ b/src/aleph/api_entrypoint.py @@ -60,7 +60,7 @@ async def configure_aiohttp_app( ipfs_service=ipfs_service, node_cache=node_cache, ) - signature_verifier = SignatureVerifier() + signature_verifier = SignatureVerifier(rpc_url=config.ethereum.api_url.value) app = create_aiohttp_app( max_file_size=config.storage.max_file_size.value, diff --git a/src/aleph/chains/assets/erc6492_validator_bytecode.hex b/src/aleph/chains/assets/erc6492_validator_bytecode.hex new file mode 100644 index 000000000..14084f559 --- /dev/null +++ b/src/aleph/chains/assets/erc6492_validator_bytecode.hex @@ -0,0 +1 @@ +0x608060405234801561000f575f5ffd5b506040516106fb3803806106fb83398101604081905261002e91610559565b5f61003a848484610045565b9050805f526001601ff35b5f5f846001600160a01b0316803b806020016040519081016040528181525f908060200190933c90507f649264926492649264926492649264926492649264926492649264926492649261009884610470565b036101f9575f606080858060200190518101906100b591906105ae565b865192955090935091505f03610174575f836001600160a01b0316836040516100de919061060f565b5f604051808303815f865af19150503d805f8114610117576040519150601f19603f3d011682016040523d82523d5f602084013e61011c565b606091505b50509050806101725760405162461bcd60e51b815260206004820152601e60248201527f5369676e617475726556616c696461746f723a206465706c6f796d656e74000060448201526064015b60405180910390fd5b505b604051630b135d3f60e11b808252906001600160a01b038a1690631626ba7e906101a4908b908690600401610625565b602060405180830381865afa1580156101bf573d5f5f3e3d5ffd5b505050506040513d601f19601f820116820180604052508101906101e39190610661565b6001600160e01b03191614945050505050610469565b8051156102e3575f5f866001600160a01b0316631626ba7e60e01b8787604051602401610227929190610625565b60408051601f198184030181529181526020820180516001600160e01b03166001600160e01b0319909416939093179092529051610265919061060f565b5f60405180830381855afa9150503d805f811461029d576040519150601f19603f3d011682016040523d82523d5f602084013e6102a2565b606091505b50915091508180156102b5575080516020145b156102e057630b135d3f60e11b6102cb82610688565b6001600160e01b031916149350505050610469565b50505b82516041146103475760405162461bcd60e51b815260206004820152603a60248201525f5160206106db5f395f51905f5260448201527f3a20696e76616c6964207369676e6174757265206c656e6774680000000000006064820152608401610169565b61034f610487565b50602083015160408085015185518693925f918591908110610373576103736106c6565b016020015160f81c9050601b811480159061039257508060ff16601c14155b156103f25760405162461bcd60e51b815260206004820152603b60248201525f5160206106db5f395f51905f5260448201527f3a20696e76616c6964207369676e617475726520762076616c756500000000006064820152608401610169565b604080515f8152602081018083528a905260ff83169181019190915260608101849052608081018390526001600160a01b038a169060019060a0016020604051602081039080840390855afa15801561044d573d5f5f3e3d5ffd5b505050602060405103516001600160a01b031614955050505050505b9392505050565b5f60208251101561047f575f5ffd5b508051015190565b60405180606001604052806003906020820280368337509192915050565b6001600160a01b03811681146104b9575f5ffd5b50565b634e487b7160e01b5f52604160045260245ffd5b5f82601f8301126104df575f5ffd5b81516001600160401b038111156104f8576104f86104bc565b604051601f8201601f19908116603f011681016001600160401b0381118282101715610526576105266104bc565b60405281815283820160200185101561053d575f5ffd5b8160208501602083015e5f918101602001919091529392505050565b5f5f5f6060848603121561056b575f5ffd5b8351610576816104a5565b6020850151604086015191945092506001600160401b03811115610598575f5ffd5b6105a4868287016104d0565b9150509250925092565b5f5f5f606084860312156105c0575f5ffd5b83516105cb816104a5565b60208501519093506001600160401b038111156105e6575f5ffd5b6105f2868287016104d0565b604086015190935090506001600160401b03811115610598575f5ffd5b5f82518060208501845e5f920191825250919050565b828152604060208201525f82518060408401528060208501606085015e5f606082850101526060601f19601f8301168401019150509392505050565b5f60208284031215610671575f5ffd5b81516001600160e01b031981168114610469575f5ffd5b805160208201516001600160e01b03198116919060048210156106bf576001600160e01b0319600483900360031b81901b82161692505b5050919050565b634e487b7160e01b5f52603260045260245ffdfe5369676e617475726556616c696461746f72237265636f7665725369676e6572 \ No newline at end of file diff --git a/src/aleph/chains/evm.py b/src/aleph/chains/evm.py index 2de64a91e..3a6a3ba8c 100644 --- a/src/aleph/chains/evm.py +++ b/src/aleph/chains/evm.py @@ -1,8 +1,14 @@ import functools +import importlib.resources import logging +from typing import Optional +from eth_abi.abi import encode from eth_account import Account -from eth_account.messages import encode_defunct +from eth_account.messages import _hash_eip191_message, encode_defunct +from eth_typing import HexStr +from eth_utils.address import to_checksum_address +from web3 import AsyncHTTPProvider, AsyncWeb3 from aleph.chains.common import get_verification_buffer from aleph.schemas.pending_messages import BasePendingMessage @@ -12,36 +18,166 @@ LOGGER = logging.getLogger("chains.evm") +ERC6492_MAGIC = bytes.fromhex( + "6492649264926492649264926492649264926492649264926492649264926492" +) +ERC1271_MAGIC = bytes.fromhex("1626ba7e") +IS_VALID_SIGNATURE_SELECTOR = bytes.fromhex("1626ba7e") + + +# ERC-6492 UniversalSigValidator deployer bytecode (reference impl from +# AmbireTech/signature-validator, EIP-6492). It is NOT a pre-deployed contract +# — it is sent as contract-creation data in eth_call. The bytecode receives +# (address signer, bytes32 hash, bytes signature) as constructor args, deploys +# the UniversalSigValidator on the fly, runs isValidSig, and returns a single +# byte: 0x01 for valid, 0x00 for invalid. +@functools.lru_cache(maxsize=1) +def _universal_validator_bytecode() -> bytes: + resource = ( + importlib.resources.files("aleph.chains.assets") + / "erc6492_validator_bytecode.hex" + ) + hex_str = resource.read_text(encoding="utf-8").strip() + return bytes.fromhex(hex_str.removeprefix("0x")) + class EVMVerifier(Verifier): + def __init__(self, rpc_url: Optional[str] = None): + self.rpc_url = rpc_url + self._w3: Optional[AsyncWeb3] = None + + def _get_web3_client(self) -> Optional[AsyncWeb3]: + if self.rpc_url is None: + return None + if self._w3 is None: + self._w3 = AsyncWeb3(AsyncHTTPProvider(self.rpc_url)) + return self._w3 + + @staticmethod + def _is_erc6492(sig_bytes: bytes) -> bool: + return len(sig_bytes) >= 32 and sig_bytes[-32:] == ERC6492_MAGIC + + async def _verify_erc6492( + self, + w3: AsyncWeb3, + sender: str, + message_hash: bytes, + signature: bytes, + ) -> bool: + """Validate an ERC-6492 counterfactual signature. + + Uses the EIP-6492 off-chain verification pattern: send the + ValidateSigOffchain deployer bytecode as contract-creation data in + eth_call (no `to` field). The bytecode runs as a constructor, deploys + the UniversalSigValidator inline, simulates the factory deployment, + calls isValidSignature, and returns 1 byte: 0x01 valid / 0x00 invalid. + """ + try: + constructor_args = encode( + ["address", "bytes32", "bytes"], + [sender, message_hash, signature], + ) + deploy_data = _universal_validator_bytecode() + constructor_args + result = await w3.eth.call({"data": HexStr("0x" + deploy_data.hex())}) + return result == b"\x01" + except Exception: + LOGGER.exception( + "Error running ERC-6492 validation bytecode for %s", sender + ) + return False + + async def _verify_erc1271( + self, + w3: AsyncWeb3, + sender: str, + message_hash: bytes, + signature: bytes, + ) -> bool: + """Call isValidSignature on a deployed ERC-1271 contract.""" + try: + calldata = IS_VALID_SIGNATURE_SELECTOR + encode( + ["bytes32", "bytes"], [message_hash, signature] + ) + result = await w3.eth.call({"to": sender, "data": calldata}) + return result[:4] == ERC1271_MAGIC + except Exception: + LOGGER.exception("Error calling isValidSignature on %s", sender) + return False + async def verify_signature(self, message: BasePendingMessage) -> bool: - """Verifies a signature of a message, return True if verified, false if not""" + """Verifies a signature of a message, return True if verified, false if not. + Detection / fallback order (cheapest first, except ERC-6492 which is detected upfront): + - ERC-6492 magic suffix → UniversalSigValidator eth_call (skip ECDSA) + - Plain ECDSA ecrecover (0 RPC calls) + - ERC-1271 isValidSignature on deployed contract (1 eth_call) + """ verification = get_verification_buffer(message) - - message_hash = await run_in_executor( - None, functools.partial(encode_defunct, text=verification.decode("utf-8")) + message_hash_obj = await run_in_executor( + None, + functools.partial(encode_defunct, text=verification.decode("utf-8")), ) + raw_hash: bytes = _hash_eip191_message(message_hash_obj) + + if not message.signature: + return False - verified = False try: - # we assume the signature is a valid string + sig_bytes = bytes.fromhex(message.signature.removeprefix("0x")) + except ValueError: + sig_bytes = b"" + + sender_checksum = to_checksum_address(message.sender) + + # Path 1: ERC-6492 counterfactual (magic suffix detected, skip ECDSA) + if self._is_erc6492(sig_bytes): + w3 = self._get_web3_client() + if w3 is None: + LOGGER.warning( + "ERC-6492 signature for %s but no rpc_url configured", + message.sender, + ) + return False + return await self._verify_erc6492(w3, sender_checksum, raw_hash, sig_bytes) + + # Path 2: plain ECDSA + try: address = await run_in_executor( None, functools.partial( - Account.recover_message, message_hash, signature=message.signature + Account.recover_message, + message_hash_obj, + signature=message.signature, ), ) if address.lower() == message.sender.lower(): - verified = True - else: - LOGGER.warning( - "Received bad signature from %s for %s" % (address, message.sender) - ) - return False + return True + LOGGER.warning( + "ECDSA recovered %s != sender %s, falling back to ERC-1271", + address, + message.sender, + ) + except Exception: + LOGGER.debug( + "ECDSA recovery failed for %s, trying ERC-1271", message.sender + ) + # Path 3: ERC-1271 (deployed contract) + w3 = self._get_web3_client() + if w3 is None: + LOGGER.warning( + "Signature for %s failed ECDSA and no rpc_url configured for ERC-1271", + message.sender, + ) + return False + + try: + code = await w3.eth.get_code(sender_checksum) except Exception: - LOGGER.exception("Error processing signature for %s" % message.sender) - verified = False + LOGGER.exception("Error checking contract code for %s", message.sender) + return False + + if not code or code == b"0x": + return False - return verified + return await self._verify_erc1271(w3, sender_checksum, raw_hash, sig_bytes) diff --git a/src/aleph/chains/signature_verifier.py b/src/aleph/chains/signature_verifier.py index 447c9cab6..00ea4e25c 100644 --- a/src/aleph/chains/signature_verifier.py +++ b/src/aleph/chains/signature_verifier.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, Optional from aleph_message.models import Chain @@ -19,39 +19,48 @@ class SignatureVerifier: verifiers: Dict[Chain, Verifier] - def __init__(self): + def __init__(self, rpc_url: Optional[str] = None): + # Smart wallet validation (ERC-1271 / ERC-6492) is chain-specific: the + # wallet's EIP-712 domain uses `block.chainid`, so verifying a signature + # from Base (or any other chain) against the Ethereum mainnet RPC would + # produce false negatives. Until per-chain RPCs are configured, only + # Ethereum mainnet gets the RPC-backed paths. Every other EVM chain + # keeps the previous behavior: plain ECDSA only. + evm = EVMVerifier() + eth = EthereumVerifier(rpc_url=rpc_url) + etherlink = EthereumVerifier() self.verifiers = { - Chain.ARBITRUM: EVMVerifier(), + Chain.ARBITRUM: evm, Chain.AVAX: AvalancheConnector(), - Chain.BASE: EVMVerifier(), - Chain.BLAST: EVMVerifier(), - Chain.BOB: EVMVerifier(), - Chain.BSC: EVMVerifier(), - Chain.CYBER: EVMVerifier(), + Chain.BASE: evm, + Chain.BLAST: evm, + Chain.BOB: evm, + Chain.BSC: evm, + Chain.CYBER: evm, Chain.CSDK: CosmosConnector(), Chain.DOT: SubstrateConnector(), Chain.ECLIPSE: SolanaConnector(), - Chain.ETH: EthereumVerifier(), - Chain.ETHERLINK: EthereumVerifier(), - Chain.FRAXTAL: EVMVerifier(), - Chain.HYPE: EVMVerifier(), - Chain.INK: EVMVerifier(), - Chain.LENS: EVMVerifier(), - Chain.METIS: EVMVerifier(), - Chain.MODE: EVMVerifier(), - Chain.NEO: EVMVerifier(), + Chain.ETH: eth, + Chain.ETHERLINK: etherlink, + Chain.FRAXTAL: evm, + Chain.HYPE: evm, + Chain.INK: evm, + Chain.LENS: evm, + Chain.METIS: evm, + Chain.MODE: evm, + Chain.NEO: evm, Chain.NULS: NulsConnector(), Chain.NULS2: Nuls2Verifier(), - Chain.LINEA: EVMVerifier(), - Chain.LISK: EVMVerifier(), - Chain.OPTIMISM: EVMVerifier(), - Chain.POL: EVMVerifier(), + Chain.LINEA: evm, + Chain.LISK: evm, + Chain.OPTIMISM: evm, + Chain.POL: evm, Chain.SOL: SolanaConnector(), - Chain.SONIC: EVMVerifier(), - Chain.UNICHAIN: EVMVerifier(), + Chain.SONIC: evm, + Chain.UNICHAIN: evm, Chain.TEZOS: TezosVerifier(), - Chain.WORLDCHAIN: EVMVerifier(), - Chain.ZORA: EVMVerifier(), + Chain.WORLDCHAIN: evm, + Chain.ZORA: evm, } async def verify_signature(self, message: BasePendingMessage) -> None: diff --git a/src/aleph/jobs/fetch_pending_messages.py b/src/aleph/jobs/fetch_pending_messages.py index 6fa60e04a..5a24355ae 100644 --- a/src/aleph/jobs/fetch_pending_messages.py +++ b/src/aleph/jobs/fetch_pending_messages.py @@ -309,7 +309,7 @@ async def fetch_messages_task(config: Config): ipfs_service=ipfs_service, node_cache=node_cache, ) - signature_verifier = SignatureVerifier() + signature_verifier = SignatureVerifier(rpc_url=config.ethereum.api_url.value) message_handler = MessageHandler( signature_verifier=signature_verifier, storage_service=storage_service, diff --git a/src/aleph/jobs/process_pending_messages.py b/src/aleph/jobs/process_pending_messages.py index 4bf5819a1..c9492f842 100644 --- a/src/aleph/jobs/process_pending_messages.py +++ b/src/aleph/jobs/process_pending_messages.py @@ -172,7 +172,7 @@ async def fetch_and_process_messages_task(config: Config): ipfs_service=ipfs_service, node_cache=node_cache, ) - signature_verifier = SignatureVerifier() + signature_verifier = SignatureVerifier(rpc_url=config.ethereum.api_url.value) message_handler = MessageHandler( signature_verifier=signature_verifier, storage_service=storage_service, diff --git a/tests/chains/test_evm.py b/tests/chains/test_evm.py index f187fc7f7..1d917ad93 100644 --- a/tests/chains/test_evm.py +++ b/tests/chains/test_evm.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -52,3 +52,201 @@ async def test_verify_bad_evm_signature(evm_message: BasePendingMessage): evm_message.signature = "baba" result = await verifier.verify_signature(evm_message) assert result is False + + +def test_evm_verifier_accepts_rpc_url(): + """EVMVerifier can be constructed with an rpc_url without errors.""" + verifier = EVMVerifier(rpc_url="http://localhost:8545") + assert verifier.rpc_url == "http://localhost:8545" + + +def test_evm_verifier_no_rpc_url(): + """EVMVerifier can still be constructed with no rpc_url.""" + verifier = EVMVerifier() + assert verifier.rpc_url is None + + +@pytest.fixture +def erc1271_message() -> BasePendingMessage: + """Message signed by a deployed Kernel smart wallet (86-byte inner sig, no ERC-6492 wrapper).""" + return parse_message( + { + "item_hash": "442b2570512753ed1b41f84e8202023f19fd5d5ba31117c8319ea173a92488bd", + "type": "POST", + "chain": "ETH", + "sender": "0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635", + "signature": "0x01845adb2c711129d4f3966735ed98a9f09fc4ce5794f8df9bcc3e2fa2049519666e9977ff76f9c99322db6a1f1117f3955411b2ae316b72e49bd1743a5dee905ea4f27c4e7912479995f6b99eb56c44349dabe3731c", + "time": 1730410918.0, + "item_type": "inline", + "item_content": '{"address":"0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635","time":1730410918.0,"content":{},"type":"test"}', + "channel": "TEST", + } + ) + + +@pytest.mark.asyncio +async def test_verify_erc1271_deployed_valid(erc1271_message: BasePendingMessage): + """ERC-1271: valid sig from a deployed smart wallet returns True.""" + verifier = EVMVerifier(rpc_url="http://localhost:8545") + + mock_w3 = AsyncMock() + mock_w3.eth.get_code = AsyncMock(return_value=b"\x60\x80") + mock_w3.eth.call = AsyncMock(return_value=bytes.fromhex("1626ba7e" + "00" * 28)) + + with patch.object(verifier, "_get_web3_client", return_value=mock_w3): + result = await verifier.verify_signature(erc1271_message) + + assert result is True + + +@pytest.mark.asyncio +async def test_verify_erc1271_deployed_invalid(erc1271_message: BasePendingMessage): + """ERC-1271: wrong response from isValidSignature returns False.""" + verifier = EVMVerifier(rpc_url="http://localhost:8545") + + mock_w3 = AsyncMock() + mock_w3.eth.get_code = AsyncMock(return_value=b"\x60\x80") + mock_w3.eth.call = AsyncMock(return_value=bytes.fromhex("deadbeef" + "00" * 28)) + + with patch.object(verifier, "_get_web3_client", return_value=mock_w3): + result = await verifier.verify_signature(erc1271_message) + + assert result is False + + +@pytest.mark.asyncio +async def test_verify_erc1271_no_rpc_falls_back_to_ecdsa( + erc1271_message: BasePendingMessage, +): + """Without rpc_url, smart wallet sigs fail gracefully (no RPC available).""" + verifier = EVMVerifier() + result = await verifier.verify_signature(erc1271_message) + assert result is False + + +@pytest.mark.asyncio +async def test_verify_erc1271_skipped_when_no_code( + erc1271_message: BasePendingMessage, +): + """If sender has no deployed code, ERC-1271 path is skipped.""" + verifier = EVMVerifier(rpc_url="http://localhost:8545") + + mock_w3 = AsyncMock() + mock_w3.eth.get_code = AsyncMock(return_value=b"") + + with patch.object(verifier, "_get_web3_client", return_value=mock_w3): + result = await verifier.verify_signature(erc1271_message) + + assert result is False + mock_w3.eth.call.assert_not_called() + + +# The real ERC-6492 signature from message 6f699b25... +ERC6492_SIG = ( + "0x000000000000000000000000d703aae79538628d27099b8c4f621be4ccd142d5" + "0000000000000000000000000000000000000000000000000000000000000060" + "0000000000000000000000000000000000000000000000000000000000000260" + "00000000000000000000000000000000000000000000000000000000000001c4" + "c5265d5d000000000000000000000000aac5d4240af87249b3f71bc8e4a2cae074a3e419" + "0000000000000000000000000000000000000000000000000000000000000060" + "0000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000001243c3b752b" + "01845ADb2C711129d4f3966735eD98a9F09fC4cE57" + "0000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000" + "0000a000000000000000000000000000000000000000000000000000000000000000e0" + "0000000000000000000000000000000000000000000000000000000000000100" + "0000000000000000000000000000000000000000000000000000000000000014" + "fFFEfCDE25e1d00474530f1A7b90D02CEda94fD7" + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000056" + "01845ADb2C711129d4f3966735eD98a9F09fC4cE57" + "94f8df9bcc3e2fa2049519666e9977ff76f9c99322db6a1f1117f3955411b2ae" + "316b72e49bd1743a5dee905ea4f27c4e7912479995f6b99eb56c44349dabe373" + "1c000000000000000000" + "006492649264926492649264926492649264926492649264926492649264926492" +) + + +@pytest.fixture +def erc6492_message() -> BasePendingMessage: + """Message with ERC-6492 counterfactual smart wallet signature.""" + return parse_message( + { + "item_hash": "316b07861dee3fcaa40d10a563fec5e8d6b4a81514b1265941bec861ce3e95ae", + "type": "POST", + "chain": "ETH", + "sender": "0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635", + "signature": ERC6492_SIG, + "time": 1745362074.0, + "item_type": "inline", + "item_content": '{"address":"0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635","time":1745362074.0,"content":{"key":"test2","label":"2222"},"type":"ALEPH-SSH"}', + "channel": "ALEPH-CLOUDSOLUTIONS", + } + ) + + +@pytest.mark.asyncio +async def test_verify_erc6492_counterfactual_valid( + erc6492_message: BasePendingMessage, +): + """ERC-6492: valid sig from a counterfactual smart wallet returns True.""" + verifier = EVMVerifier(rpc_url="http://localhost:8545") + + mock_w3 = AsyncMock() + # The validator bytecode returns 1 byte: 0x01 = valid + mock_w3.eth.call = AsyncMock(return_value=b"\x01") + + with patch.object(verifier, "_get_web3_client", return_value=mock_w3): + result = await verifier.verify_signature(erc6492_message) + + assert result is True + # Confirm it's a contract-creation call: no `to` field, data is the + # validator bytecode + ABI-encoded (signer, hash, signature) + call_args = mock_w3.eth.call.call_args[0][0] + assert "to" not in call_args + assert call_args["data"].startswith("0x608060") + + +@pytest.mark.asyncio +async def test_verify_erc6492_counterfactual_invalid( + erc6492_message: BasePendingMessage, +): + """ERC-6492: validator bytecode returns 0x00 → invalid.""" + verifier = EVMVerifier(rpc_url="http://localhost:8545") + + mock_w3 = AsyncMock() + mock_w3.eth.call = AsyncMock(return_value=b"\x00") + + with patch.object(verifier, "_get_web3_client", return_value=mock_w3): + result = await verifier.verify_signature(erc6492_message) + + assert result is False + + +@pytest.mark.asyncio +async def test_erc6492_detection_skips_ecdsa( + erc6492_message: BasePendingMessage, +): + """ERC-6492 sigs skip ECDSA entirely and go straight to bytecode validation.""" + verifier = EVMVerifier(rpc_url="http://localhost:8545") + + mock_w3 = AsyncMock() + mock_w3.eth.call = AsyncMock(return_value=b"\x01") + + with patch.object(verifier, "_get_web3_client", return_value=mock_w3): + with patch("aleph.chains.evm.Account.recover_message") as mock_recover: + await verifier.verify_signature(erc6492_message) + mock_recover.assert_not_called() + + +@pytest.mark.asyncio +async def test_verify_erc6492_no_rpc_fails(erc6492_message: BasePendingMessage): + """Without rpc_url, ERC-6492 sigs fail (cannot call UniversalSigValidator).""" + verifier = EVMVerifier() + result = await verifier.verify_signature(erc6492_message) + assert result is False diff --git a/tests/chains/test_evm_integration.py b/tests/chains/test_evm_integration.py new file mode 100644 index 000000000..7f9feaaeb --- /dev/null +++ b/tests/chains/test_evm_integration.py @@ -0,0 +1,82 @@ +""" +Integration tests for EVMVerifier against a real Ethereum mainnet RPC. + +These tests are marked `network` and excluded from the default pytest run. +Enable them explicitly: + + hatch run testing:test tests/chains/test_evm_integration.py -m network -v + +Or override addopts: + + hatch run testing:test tests/chains/test_evm_integration.py -m network -v \ + --override-ini="addopts=" + +Uses a public mainnet RPC by default. Override with ALEPH_TEST_ETH_RPC if you +have your own node. +""" + +import os + +import pytest + +from aleph.chains.evm import EVMVerifier +from aleph.schemas.pending_messages import BasePendingMessage, parse_message + +ETH_MAINNET_RPC = os.environ.get( + "ALEPH_TEST_ETH_RPC", "https://ethereum-rpc.publicnode.com" +) + + +# Real Aleph message signed by a Privy/Kernel counterfactual smart wallet. +# Source: the production Aleph network, originally rejected before EIP-6492 +# support was added. +# item_hash: f4daf9c0dadd7aa89c37e62e24f90a032183ba3b829b2bd2cf87568a940fd0a8 +REAL_ERC6492_MESSAGE = { + "item_hash": "f4daf9c0dadd7aa89c37e62e24f90a032183ba3b829b2bd2cf87568a940fd0a8", + "type": "POST", + "chain": "ETH", + "sender": "0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635", + "time": 1776949817.862, + "item_type": "inline", + "item_content": ( + '{"type":"ALEPH-SSH",' + '"address":"0xa9F3Cd4E416c6e911DB3DcB5CA6CD77e9F861635",' + '"content":{"key":"test1","label":"test1"},' + '"time":1776949817.862}' + ), + "channel": "ALEPH-CLOUDSOLUTIONS", + "signature": "0x000000000000000000000000d703aae79538628d27099b8c4f621be4ccd142d50000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000001c4c5265d5d000000000000000000000000aac5d4240af87249b3f71bc8e4a2cae074a3e4190000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001243c3b752b01845ADb2C711129d4f3966735eD98a9F09fC4cE570000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000014fFFEfCDE25e1d00474530f1A7b90D02CEda94fD7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005601845ADb2C711129d4f3966735eD98a9F09fC4cE57ad3840a219707e52978ad891b851ac7302c95785dd6e233f010c205018312c7b1232d3eb5e60be5a12e41d3be3a9635660eea241fc2ef92cc461abf00d44b4831b000000000000000000006492649264926492649264926492649264926492649264926492649264926492", # noqa: E501 +} + + +@pytest.fixture +def real_erc6492_message() -> BasePendingMessage: + return parse_message(REAL_ERC6492_MESSAGE) + + +@pytest.mark.network +@pytest.mark.asyncio +async def test_erc6492_validation_against_mainnet( + real_erc6492_message: BasePendingMessage, +): + """End-to-end: real ERC-6492 sig + real mainnet RPC + EIP-6492 bytecode. + + This test exercises the full happy path against a live Ethereum mainnet + node: + 1. Detects the 0x6492…6492 magic suffix. + 2. Builds the ValidateSigOffchain deploy_data (bytecode + ABI-encoded args). + 3. eth_call with no `to` field → the bytecode runs as a constructor, + deploys UniversalSigValidator inline, simulates the Kernel factory + deployment, and calls isValidSignature. + 4. Asserts the returned byte is 0x01 (valid). + + Verifies that the bogus-address bug is fixed and the EIP-6492 + contract-creation pattern works as specified. + """ + verifier = EVMVerifier(rpc_url=ETH_MAINNET_RPC) + result = await verifier.verify_signature(real_erc6492_message) + assert result is True, ( + "Expected the real ERC-6492 signature to validate against mainnet. " + "If this fails, either the RPC is down or the bytecode asset drifted " + "from the EIP-6492 reference implementation." + )