Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions crates/evm/core/src/tempo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions crates/forge/assets/tempo/MailTemplate.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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]: <https://docs.tempo.xyz/protocol/tips/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");
Comment thread
0xrusowsky marked this conversation as resolved.

// `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]));
}
}
121 changes: 116 additions & 5 deletions crates/forge/assets/tempo/MailTemplate.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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;

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);

Expand All @@ -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);
Expand All @@ -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;
}
}
Loading