diff --git a/crates/evm/core/src/tempo.rs b/crates/evm/core/src/tempo.rs index 5e0cbefec56d3..eb29696858ff2 100644 --- a/crates/evm/core/src/tempo.rs +++ b/crates/evm/core/src/tempo.rs @@ -11,11 +11,14 @@ use tempo_contracts::{ ARACHNID_CREATE2_FACTORY_ADDRESS, CREATEX_ADDRESS, CreateX, MULTICALL3_ADDRESS, Multicall3, PERMIT2_ADDRESS, Permit2, SAFE_DEPLOYER_ADDRESS, SafeDeployer, contracts::ARACHNID_CREATE2_FACTORY_BYTECODE, + precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, NONCE_PRECOMPILE_ADDRESS, + SIGNATURE_VERIFIER_ADDRESS, STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, + TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, VALIDATOR_CONFIG_ADDRESS, + VALIDATOR_CONFIG_V2_ADDRESS, + }, }; use tempo_precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, NONCE_PRECOMPILE_ADDRESS, STABLECOIN_DEX_ADDRESS, - TIP_FEE_MANAGER_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, - VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, error::TempoPrecompileError, storage::{PrecompileStorageProvider, StorageCtx}, tip20::{ISSUER_ROLE, ITIP20, TIP20Token}, @@ -25,12 +28,6 @@ use tempo_precompiles::{ pub use tempo_contracts::precompiles::PATH_USD_ADDRESS; -// TODO: remove once we can re-export from tempo_precompiles instead. -pub const SIGNATURE_VERIFIER_ADDRESS: Address = - address!("0x5165300000000000000000000000000000000000"); -pub const ADDRESS_REGISTRY_ADDRESS: Address = - address!("0xFDC0000000000000000000000000000000000000"); - /// All well-known Tempo precompile addresses. pub const TEMPO_PRECOMPILE_ADDRESSES: &[Address] = &[ NONCE_PRECOMPILE_ADDRESS, @@ -92,7 +89,7 @@ pub fn initialize_tempo_genesis_inner( // Create PathUSD token: 0x20C0000000000000000000000000000000000000 let path_usd_token_address = create_and_mint_token( - address!("20C0000000000000000000000000000000000000"), + PATH_USD_ADDRESS, "PathUSD", "PathUSD", "USD", diff --git a/crates/forge/assets/tempo/MailTemplate.sol b/crates/forge/assets/tempo/MailTemplate.sol index 92911c3f562a9..2caed92d47517 100644 --- a/crates/forge/assets/tempo/MailTemplate.sol +++ b/crates/forge/assets/tempo/MailTemplate.sol @@ -2,23 +2,76 @@ pragma solidity ^0.8.13; import {ITIP20} from "tempo-std/interfaces/ITIP20.sol"; +import {StdPrecompiles} from "tempo-std/StdPrecompiles.sol"; +/// @title Mail +/// @notice Send mail with TIP-20 token attachments on Tempo. +/// +/// Supports two modes: +/// 1. Direct — call `sendMail()` yourself (uses `msg.sender`). +/// 2. Relayed — sign a mail off-chain and let anyone deliver it on-chain. +/// +/// Relayed mode uses the [TIP-1020] `SignatureVerifier` precompile to verify the +/// sender's Tempo signature. Unlike Ethereum's `ecrecover`, this precompile: +/// - Supports secp256k1, P256, and WebAuthn signature types +/// - Reverts on invalid signatures instead of returning `address(0)` +/// - Maintains forward compatibility with future Tempo account types +/// +/// [TIP-1020]: contract Mail { + /// @notice Emitted when a mail is sent, either directly or via a relayer. event MailSent(address indexed from, address indexed to, string message, Attachment attachment); + /// @notice A TIP-20 token transfer bundled with a mail. struct Attachment { uint256 amount; bytes32 memo; } + /// @notice The TIP-20 token used for mail attachments. ITIP20 public token; + /// @notice Per-sender nonce to prevent signature replay on relayed mails (requires T3). + mapping(address => uint256) public nonces; + constructor(ITIP20 token_) { token = token_; } + /// @notice Send mail directly (sender = msg.sender). function sendMail(address to, string memory message, Attachment memory attachment) external { token.transferFromWithMemo(msg.sender, to, attachment.amount, attachment.memo); emit MailSent(msg.sender, to, message, attachment); } + + /// @notice Send mail on behalf of `from` using their off-chain Tempo signature (requires T3). + /// @dev The sender must have pre-approved this contract to spend their tokens. + function sendMail( + address from, + address to, + string memory message, + Attachment memory attachment, + bytes calldata signature + ) external { + bytes32 hash = getDigest(from, to, message, attachment); + + // `verify()` returns `false` on signer mismatch, reverts on malformed signatures. + require(StdPrecompiles.SIGNATURE_VERIFIER.verify(from, hash, signature), "invalid signature"); + + // `recover()` returns the signer address directly, reverts on malformed signatures. + require(StdPrecompiles.SIGNATURE_VERIFIER.recover(hash, signature) == from, "invalid signature"); + + nonces[from]++; + token.transferFromWithMemo(from, to, attachment.amount, attachment.memo); + emit MailSent(from, to, message, attachment); + } + + /// @notice Compute the digest a sender must sign to authorize a relayed mail. + function getDigest(address from, address to, string memory message, Attachment memory attachment) + public + view + returns (bytes32) + { + return keccak256(abi.encode(address(this), block.chainid, from, to, message, attachment, nonces[from])); + } } diff --git a/crates/forge/assets/tempo/MailTemplate.t.sol b/crates/forge/assets/tempo/MailTemplate.t.sol index f5a9976715026..b1749db5df0bf 100644 --- a/crates/forge/assets/tempo/MailTemplate.t.sol +++ b/crates/forge/assets/tempo/MailTemplate.t.sol @@ -8,6 +8,7 @@ import {StdPrecompiles} from "tempo-std/StdPrecompiles.sol"; import {StdTokens} from "tempo-std/StdTokens.sol"; import {Mail} from "../src/Mail.sol"; +/// @notice Tests for direct mail sending (no signature verification). contract MailTest is Test { ITIP20 public token; Mail public mail; @@ -15,7 +16,7 @@ contract MailTest is Test { address public constant ALICE = address(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); address public constant BOB = address(0x70997970C51812dc3A010C7d01b50e0d17dc79C8); - function setUp() public { + function setUp() public virtual { address feeToken = vm.envOr("TEMPO_FEE_TOKEN", StdTokens.ALPHA_USD_ADDRESS); StdPrecompiles.TIP_FEE_MANAGER.setUserToken(feeToken); @@ -35,11 +36,10 @@ contract MailTest is Test { Mail.Attachment memory attachment = Mail.Attachment({amount: 100 * 10 ** token.decimals(), memo: "Invoice #1234"}); - vm.prank(ALICE); + vm.startPrank(ALICE); token.approve(address(mail), attachment.amount); - - vm.prank(ALICE); - mail.sendMail(BOB, "Hello Alice, this is a unit test mail.", attachment); + mail.sendMail(BOB, "Hello Bob, here is your invoice.", attachment); + vm.stopPrank(); assertEq(token.balanceOf(BOB), attachment.amount); assertEq(token.balanceOf(ALICE), 100_000 * 10 ** token.decimals() - attachment.amount); @@ -62,3 +62,114 @@ contract MailTest is Test { assertEq(token.balanceOf(ALICE), mintAmount - sendAmount); } } + +/// @notice Tests for relayed mail using the TIP-1020 SignatureVerifier precompile (requires T3). +/// forge-config: default.hardfork = "tempo:T3" +contract MailRelayTest is MailTest { + // secp256k1 keys (used by vm.sign / vm.addr) + uint256 internal constant ALICE_PK = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + uint256 internal constant BOB_PK = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + + // P256 key (used by vm.signP256 / vm.publicKeyP256) + uint256 internal constant CAROL_P256_PK = 0x1; + address internal CAROL; + bytes32 internal carolPubX; + bytes32 internal carolPubY; + + uint256 internal constant P256_ORDER = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551; + uint256 internal constant P256N_HALF = 0x7FFFFFFF800000007FFFFFFFFFFFFFFFDE737D56D38BCF4279DCE5617E3192A8; + + function setUp() public override { + super.setUp(); + + // Derive P256 public key and Tempo address for Carol + (uint256 x, uint256 y) = vm.publicKeyP256(CAROL_P256_PK); + carolPubX = bytes32(x); + carolPubY = bytes32(y); + CAROL = address(uint160(uint256(keccak256(abi.encodePacked(x, y))))); + } + + /// @notice Relayed send with a secp256k1 signature — Alice signs, Bob delivers. + function test_SendMailWithSecp256k1Signature() public { + token.mint(ALICE, 100_000 * 10 ** token.decimals()); + + Mail.Attachment memory attachment = + Mail.Attachment({amount: 100 * 10 ** token.decimals(), memo: "Invoice #1234"}); + + vm.prank(ALICE); + token.approve(address(mail), attachment.amount); + + string memory message = "Hello Bob, here is your invoice."; + bytes32 digest = mail.getDigest(ALICE, BOB, message, attachment); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, digest); + + vm.prank(BOB); + mail.sendMail(ALICE, BOB, message, attachment, abi.encodePacked(r, s, v)); + + assertEq(token.balanceOf(BOB), attachment.amount); + assertEq(mail.nonces(ALICE), 1); + } + + /// @notice Relayed send with a P256 signature — Carol signs, Bob delivers. + function test_SendMailWithP256Signature() public { + token.mint(CAROL, 100_000 * 10 ** token.decimals()); + + Mail.Attachment memory attachment = + Mail.Attachment({amount: 100 * 10 ** token.decimals(), memo: "Invoice #1234"}); + + vm.prank(CAROL); + token.approve(address(mail), attachment.amount); + + string memory message = "Hello Bob, signed with P256."; + bytes32 digest = mail.getDigest(CAROL, BOB, message, attachment); + (bytes32 r, bytes32 s) = vm.signP256(CAROL_P256_PK, digest); + s = _normalizeP256S(s); + + bytes memory sig = abi.encodePacked(uint8(0x01), r, s, carolPubX, carolPubY, uint8(0)); + + vm.prank(BOB); + mail.sendMail(CAROL, BOB, message, attachment, sig); + + assertEq(token.balanceOf(BOB), attachment.amount); + assertEq(mail.nonces(CAROL), 1); + } + + /// @notice Replaying the same signature fails (nonce incremented). + function test_ReplayReverts() public { + token.mint(ALICE, 100_000 * 10 ** token.decimals()); + + Mail.Attachment memory attachment = Mail.Attachment({amount: 50 * 10 ** token.decimals(), memo: "tip"}); + + vm.prank(ALICE); + token.approve(address(mail), attachment.amount * 2); + + string memory message = "tip"; + bytes32 digest = mail.getDigest(ALICE, BOB, message, attachment); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + mail.sendMail(ALICE, BOB, message, attachment, sig); + + vm.expectRevert(); + mail.sendMail(ALICE, BOB, message, attachment, sig); + } + + /// @notice Submitting Bob's signature as Alice's fails. + function test_WrongSignerReverts() public { + Mail.Attachment memory attachment = Mail.Attachment({amount: 100, memo: "fake"}); + + string memory message = "spoofed"; + bytes32 digest = mail.getDigest(ALICE, BOB, message, attachment); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BOB_PK, digest); + + vm.expectRevert("invalid signature"); + mail.sendMail(ALICE, BOB, message, attachment, abi.encodePacked(r, s, v)); + } + + /// @dev Normalize P256 s to low-s form (required by the precompile). + function _normalizeP256S(bytes32 s) internal pure returns (bytes32) { + uint256 sVal = uint256(s); + if (sVal > P256N_HALF) return bytes32(P256_ORDER - sVal); + return s; + } +}