Meta-Transaction 与 Gas Station 实现指南
适用链:
msg-chain-1| bech32 前缀:msg| 精度: 18 位小数 | Gas 价格: 1,000,000,000 attoMSG/gas
主网状态: No-Go
1. 概述
1.1 什么是 Meta-Transaction
元交易(Meta-Transaction)是一种允许用户在不持有链上原生代币(即 Gas 代币)的情况下提交交易的技术范式。核心思想是:用户签署一笔交易意图(intent),由第三方中继者(Relayer)将这笔签名提交到链上并支付 Gas 费用。这笔 Gas 费用可以通过链下结算、代币兑换或其他机制得到补偿。
在 MSG Chain 的语境下,用户使用 msg1... 地址格式,以 18 位小数精度表达代币数量,签署一条结构化消息。Relayer 服务读取该签名,将交易包装后提交,用户无需持有 MSG 即可完成合约交互。
1.2 Gas Station 网络模型
Gas Station(加油站)是 Meta-Transaction 的工程化实现,提供一套完整的中继服务架构:
- 用户端:钱包扩展或 dApp 前端生成 EIP-712 结构化签名,发送给 Relayer API
- Relayer 服务:接收签名交易,验证其合法性,估算 Gas,发送交易到 MSG Chain
- Forwarder 合约:链上验证签名、执行非重放检查、调用目标合约,代表用户执行操作
- Gas 代币池:Relayer 持有 MSG 代币池用于支付 Gas,可通过 CW20 代币向用户收取 Gas 费用
+--------+ EIP-712 Signature +-------------+
| User | --------------------------> | Relayer |
+--------+ | API |
| +------+------+
| Sign with private key |
| | broadcast tx
v v
+--------+ +------------------+
| Wallet | | Forwarder Contract |
+--------+ +--------+---------+
|
| delegatecall / execute
v
+------------------+
| Target Contract |
+------------------+
1.3 使用场景
1.3.1 新用户引导(Onboarding)
新用户首次进入 MSG Chain 生态时,钱包中尚无 MSG 代币。通过 Gas Station,用户可以直接使用 DEX、借贷协议或 NFT 市场零成本入门。Relayer 承担首次交互的 Gas 费用,大幅降低获客摩擦。
1.3.2 Gasless dApp
面向消费者的应用(游戏、社交、预测市场)希望消除用户的 Gas 认知负担。Meta-Transaction 可以让用户像使用 Web2 应用一样无缝操作,后台由 Relayer 处理链上费用。
- 每笔交易后端结算 Gas 成本
- 支持 CW20 代币间接支付
- 可设置日限额、频次限制
1.3.3 企业级钱包
企业或托管钱包需要集中管理 Gas 费用,而非将 MSG 分发到每个子钱包。企业部署自己的 Relayer 服务,所有子地址的交易由中心化 Gas 池出资,便于财务审计和成本控制。
- 统一的 Gas 预算管理
- 按部门或项目分账
- 交易日志与费用归集
1.3.4 批量操作
Relayer 可以将多个用户的签名交易批量提交,分摊固定成本。这在空投领取、批量 NFT 铸造等场景下尤其有用。
1.4 MSG Chain 关键参数
| 参数 | 值 |
|---|---|
| Chain ID | msg-chain-1 |
| Bech32 地址前缀 | msg |
| 小数精度 | 18 |
| Gas 价格(低) | 0.01 MSG |
| Gas 价格(固定) | 1,000,000,000 attoMSG/gas |
| Gas 价格(高) | 0.04 MSG |
| 平均出块时间 | ~6 秒 |
| CosmWasm 版本 | v1.x |
1.5 与常规交易对比
| 特性 | 常规交易 | Meta-Transaction |
|---|---|---|
| Gas 支付者 | 交易发起者 | Relayer |
| 签名者 | 交易发起者 | 用户(intent) |
| 提交者 | 交易发起者 | Relayer |
| 用户需持有 MSG | 是 | 否 |
| 实现复杂度 | 低 | 中-高 |
| 用户门槛 | 高 | 低 |
2. EIP-712 / Typed Signatures
2.1 结构化数据签名概述
EIP-712 是以太坊标准,定义了结构化数据的编码和签名方法,使签名对人类可读且不易受到重放攻击。在 MSG Chain 中,CosmWasm 合约通过 cosmwasm_std::HexBinary 或 Binary 类型接收并验证 ECDSA 签名,我们采用与 EIP-712 兼容的编码方式。
EIP-712 签名的核心组件:
- Domain Separator:区分不同域(合约地址、链 ID),防止跨合约/跨链重放
- Typed Struct:定义消息结构的 EIP-712 类型描述
- Signing Data:
domainSeparator || hashStruct(message)
2.2 EIP-712 Domain Separator 定义
在 MSG Chain 上,Domain Separator 定义为:
domainSeparator = keccak256(
abi.encode(
EIP712Domain({
name: "MSG Chain Meta-Transactions",
version: "1",
chainId: 1,
verifyingContract: forwarder_address,
salt: hex"00"
})
)
)
2.3 Meta-Transaction 类型定义
用户签名的核心消息结构:
struct MetaTransaction {
address from;
address to;
uint256 value;
bytes data;
uint256 nonce;
uint256 gasLimit;
uint256 deadline;
}
2.4 CosmWasm 中的签名验证方式
2.4.1 Cosmos SDK SIGN_MODE_DIRECT 对比
Cosmos SDK 默认使用 SIGN_MODE_DIRECT(Protobuf 序列化)和 SIGN_MODE_LEGACY_AMINO_JSON(Amino 编码)来签名交易。这两种方式对整个交易进行签名,不适合 Meta-Transaction 场景,因为 Meta-Transaction 需要让用户只签名"意图"(intent),而不关心交易的 Gas 价格、提交时间等执行细节。
| 模式 | 适用场景 | 支持 Meta-Tx |
|---|---|---|
| SIGN_MODE_DIRECT | 普通 Cosmos 交易 | 否 |
| SIGN_MODE_LEGACY_AMINO_JSON | 普通 Cosmos 交易、Ledger | 否 |
| EIP-712 结构化签名 | 合约内签名验证 | 是 |
| ECDSA secp256k1 裸签名 | 自定义场景 | 是 |
2.4.2 MSG Chain 推荐的签名方案
MSG Chain 采用基于 EIP-712 的结构化签名方案,与 CosmWasm 的 cosmwasm_std::verify_secp256k1 或 cosmwasm_std::ecdsa_recover 配合使用。
2.5 前端签名示例
const domain = {
name: "MSG Chain Meta-Transactions",
version: "1",
chainId: 1,
verifyingContract: forwarderAddress,
};
const types = {
MetaTransaction: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "data", type: "bytes" },
{ name: "nonce", type: "uint256" },
{ name: "gasLimit", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const message = {
from: userAddress,
to: contractAddress,
value: "0",
data: encodedData,
nonce: currentNonce,
gasLimit: "500000",
deadline: deadlineUnix,
};
const signature = await signer._signTypedData(domain, types, message);
2.6 完整的前端签名工具类
import { ethers } from "ethers";
export interface MetaTransaction {
from: string;
to: string;
value: string;
data: string;
nonce: number;
gasLimit: number;
deadline: number;
}
export class MetaTransactionSigner {
private domain: ethers.TypedDataDomain;
private types: Record<string, Array<ethers.TypedDataField>>;
constructor(forwarderAddress: string, chainId: number = 1) {
this.domain = {
name: "MSG Chain Meta-Transactions",
version: "1",
chainId: chainId,
verifyingContract: forwarderAddress,
};
this.types = {
MetaTransaction: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "data", type: "bytes" },
{ name: "nonce", type: "uint256" },
{ name: "gasLimit", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
}
async sign(signer: ethers.Signer, tx: MetaTransaction): Promise<string> {
return await signer._signTypedData(this.domain, this.types, tx);
}
async signAsync(
signer: ethers.Signer,
from: string,
to: string,
data: string,
nonce: number,
deadline?: number
): Promise<{ tx: MetaTransaction; signature: string }> {
const tx: MetaTransaction = {
from,
to,
value: "0",
data,
nonce,
gasLimit: 500000,
deadline: deadline ?? Math.floor(Date.now() / 1000) + 3600,
};
const signature = await this.sign(signer, tx);
return { tx, signature };
}
getTypedDataHash(tx: MetaTransaction): string {
return ethers.TypedDataEncoder.hash(this.domain, this.types, tx);
}
static recover(
forwarderAddress: string,
chainId: number,
tx: MetaTransaction,
signature: string
): string {
const domain = {
name: "MSG Chain Meta-Transactions",
version: "1",
chainId: chainId,
verifyingContract: forwarderAddress,
};
const types = {
MetaTransaction: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "data", type: "bytes" },
{ name: "nonce", type: "uint256" },
{ name: "gasLimit", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
return ethers.TypedDataEncoder.recover(domain, types, tx, signature);
}
}
2.7 Nonce 管理
每个用户地址维护一个单调递增的 nonce,防止签名重放:
- Nonce 在 Forwarder 合约中以
Map<Addr, Uint128>存储 - 每次成功执行
execute后 nonce 自增 1 - 前端必须在签名前查询当前 nonce
- Relayer 拒绝 nonce 不符合预期的签名交易
export async function fetchNonce(
cosmWasmClient: any,
forwarderContract: string,
userAddress: string
): Promise<number> {
const queryMsg = {
get_nonce: {
address: userAddress,
},
};
const result = await cosmWasmClient.queryContractSmart(
forwarderContract,
queryMsg
);
return Number(result.nonce);
}
2.8 截止时间(Deadline)
- MetaTransaction 中的
deadline字段指定有效期(Unix 时间戳,秒级) - Forwarder 合约检查
block.time > deadline时拒绝执行 - 建议设置 1 小时的有效期,给 Relayer 足够的处理时间
- 过期签名不会被 Forwarder 执行,防御时间相关的重放攻击
3. Forwarder 合约
3.1 合约架构
Forwarder 是部署在 MSG Chain 上的核心智能合约,负责验证用户签名并代表用户调用目标合约。
3.1.1 合约状态
use cosmwasm_std::{
entry_point, to_binary, Addr, Binary, Deps, DepsMut, Env,
HexBinary, MessageInfo, Response, StdError, StdResult,
Storage, Uint128, Uint64, WasmMsg, CosmosMsg, SubMsg,
ReplyOn, from_binary,
};
use cw_storage_plus::{Item, Map};
use serde::{Deserialize, Serialize};
use sha3::{Digest, Keccak256};
pub const CONTRACT_NAME: &str = "msg-chain-forwarder";
pub const CONTRACT_VERSION: &str = "1.0.0";
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
pub owner: Addr,
pub paused: bool,
pub gas_price: Uint128,
pub max_gas_limit: Uint128,
pub min_deadline_delta: Uint64,
}
pub const CONFIG: Item<Config> = Item::new("config");
pub const NONCES: Map<&Addr, Uint128> = Map::new("nonces");
pub const TRUSTED_SIGNERS: Map<&Addr, bool> = Map::new("signers");
pub const GAS_BALANCE: Item<Uint128> = Item::new("gas_balance");
3.1.2 消息定义
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub owner: String,
pub gas_price: Uint128,
pub max_gas_limit: Uint128,
pub min_deadline_delta: Uint64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
Execute {
tx: MetaTransaction,
signature: HexBinary,
recovery_id: u8,
},
ExecuteBatch {
txs: Vec<MetaTransaction>,
signatures: Vec<HexBinary>,
recovery_ids: Vec<u8>,
},
AddSigner { signer: String },
RemoveSigner { signer: String },
SetPaused { paused: bool },
SetGasPrice { price: Uint128 },
SetMaxGasLimit { limit: Uint128 },
Deposit {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
GetNonce { address: String },
GetConfig {},
GetGasBalance {},
IsSigner { address: String },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MetaTransaction {
pub from: String,
pub to: String,
pub value: Uint128,
pub data: HexBinary,
pub nonce: Uint128,
pub gas_limit: Uint128,
pub deadline: Uint64,
}
3.1.3 错误类型
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, thiserror::Error)]
pub enum ContractError {
#[error("{0}")]
Std(#[from] StdError),
#[error("Unauthorized")]
Unauthorized {},
#[error("Contract is paused")]
ContractPaused {},
#[error("Invalid signature")]
InvalidSignature {},
#[error("Nonce mismatch: expected {expected}, got {actual}")]
NonceMismatch { expected: Uint128, actual: Uint128 },
#[error("Transaction expired: deadline {deadline}, current time {current}")]
TransactionExpired { deadline: Uint64, current: Uint64 },
#[error("Gas limit exceeded: limit {limit}, required {required}")]
GasLimitExceeded { limit: Uint128, required: Uint128 },
#[error("Gas balance insufficient: balance {balance}, required {required}")]
InsufficientGasBalance { balance: Uint128, required: Uint128 },
#[error("Signer not trusted")]
SignerNotTrusted {},
#[error("Replay detected: hash {hash}")]
ReplayDetected { hash: String },
#[error("Deadline delta too small: delta {delta}, minimum {minimum}")]
DeadlineDeltaTooSmall { delta: Uint64, minimum: Uint64 },
#[error("{0}")]
Generic(String),
}
3.2 合约实例化
#[entry_point]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> StdResult<Response> {
let config = Config {
owner: deps.api.addr_validate(&msg.owner)?,
paused: false,
gas_price: msg.gas_price,
max_gas_limit: msg.max_gas_limit,
min_deadline_delta: msg.min_deadline_delta,
};
CONFIG.save(deps.storage, &config)?;
GAS_BALANCE.save(deps.storage, &Uint128::zero())?;
Ok(Response::new()
.add_attribute("action", "instantiate")
.add_attribute("owner", msg.owner))
}
3.3 execute 核心入口
#[entry_point]
pub fn execute(
deps: DepsMut,
env: Env,
_info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::Execute { tx, signature, recovery_id } => {
execute_meta_tx(deps, env, tx, signature, recovery_id)
}
ExecuteMsg::ExecuteBatch { txs, signatures, recovery_ids } => {
execute_batch(deps, env, txs, signatures, recovery_ids)
}
ExecuteMsg::AddSigner { signer } => execute_add_signer(deps, env, signer),
ExecuteMsg::RemoveSigner { signer } => execute_remove_signer(deps, env, signer),
ExecuteMsg::SetPaused { paused } => execute_set_paused(deps, env, paused),
ExecuteMsg::SetGasPrice { price } => execute_set_gas_price(deps, env, price),
ExecuteMsg::SetMaxGasLimit { limit } => execute_set_max_gas_limit(deps, env, limit),
ExecuteMsg::Deposit {} => execute_deposit(deps, env, _info),
}
}
3.4 execute_meta_tx 核心逻辑
pub fn execute_meta_tx(
deps: DepsMut,
env: Env,
tx: MetaTransaction,
signature: HexBinary,
recovery_id: u8,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
if config.paused {
return Err(ContractError::ContractPaused {});
}
let current_time = env.block.time.seconds();
if current_time > tx.deadline.u64() {
return Err(ContractError::TransactionExpired {
deadline: tx.deadline,
current: Uint64::new(current_time),
});
}
let from_addr = deps.api.addr_validate(&tx.from)?;
let expected_nonce = NONCES
.may_load(deps.storage, &from_addr)?
.unwrap_or(Uint128::zero());
if tx.nonce != expected_nonce {
return Err(ContractError::NonceMismatch {
expected: expected_nonce,
actual: tx.nonce,
});
}
let typed_data_hash = build_typed_data_hash(&tx, &env, &deps);
let recovered = recover_address(
&typed_data_hash,
signature.as_slice(),
recovery_id,
)?;
let recovered_addr = addr_from_bytes(&recovered, &deps)?;
if recovered_addr != from_addr {
return Err(ContractError::InvalidSignature {});
}
let new_nonce = expected_nonce.checked_add(Uint128::one())?;
NONCES.save(deps.storage, &from_addr, &new_nonce)?;
let sub_msg: Vec<SubMsg> = vec![SubMsg {
id: 1,
msg: CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: tx.to.clone(),
funds: vec![],
msg: Binary::from(tx.data.as_slice()),
}),
gas_limit: Some(tx.gas_limit.u128()),
reply_on: ReplyOn::Always,
}];
let gas_cost = tx.gas_limit.checked_mul(config.gas_price)?;
let gas_balance = GAS_BALANCE.load(deps.storage)?;
if gas_balance < gas_cost {
return Err(ContractError::InsufficientGasBalance {
balance: gas_balance,
required: gas_cost,
});
}
let new_balance = gas_balance.checked_sub(gas_cost)?;
GAS_BALANCE.save(deps.storage, &new_balance)?;
Ok(Response::new()
.add_submessages(sub_msg)
.add_attribute("action", "execute_meta_tx")
.add_attribute("from", &tx.from)
.add_attribute("to", &tx.to)
.add_attribute("nonce", tx.nonce.to_string())
.add_attribute("gas_cost", gas_cost.to_string()))
}
3.5 批量执行
pub fn execute_batch(
deps: DepsMut,
env: Env,
txs: Vec<MetaTransaction>,
signatures: Vec<HexBinary>,
recovery_ids: Vec<u8>,
) -> Result<Response, ContractError> {
if txs.len() != signatures.len() || txs.len() != recovery_ids.len() {
return Err(ContractError::Generic("Mismatched input lengths".to_string()));
}
let config = CONFIG.load(deps.storage)?;
if config.paused {
return Err(ContractError::ContractPaused {});
}
let mut response = Response::new();
let mut total_gas_cost = Uint128::zero();
for (i, tx) in txs.iter().enumerate() {
let current_time = env.block.time.seconds();
if current_time > tx.deadline.u64() {
continue;
}
let from_addr = deps.api.addr_validate(&tx.from)?;
let expected_nonce = NONCES
.may_load(deps.storage, &from_addr)?
.unwrap_or(Uint128::zero());
if tx.nonce != expected_nonce {
continue;
}
let typed_data_hash = build_typed_data_hash(tx, &env, &deps);
let recovered = recover_address(
&typed_data_hash,
signatures[i].as_slice(),
recovery_ids[i],
)?;
let recovered_addr = addr_from_bytes(&recovered, &deps)?;
if recovered_addr != from_addr {
continue;
}
let new_nonce = expected_nonce.checked_add(Uint128::one())?;
NONCES.save(deps.storage, &from_addr, &new_nonce)?;
let sub_msg = SubMsg {
id: (i + 1) as u64,
msg: CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: tx.to.clone(),
funds: vec![],
msg: Binary::from(tx.data.as_slice()),
}),
gas_limit: Some(tx.gas_limit.u128()),
reply_on: ReplyOn::Never,
};
response = response.add_submessage(sub_msg);
let gas_cost = tx.gas_limit.checked_mul(config.gas_price)?;
total_gas_cost = total_gas_cost.checked_add(gas_cost)?;
}
let gas_balance = GAS_BALANCE.load(deps.storage)?;
if gas_balance < total_gas_cost {
return Err(ContractError::InsufficientGasBalance {
balance: gas_balance,
required: total_gas_cost,
});
}
let new_balance = gas_balance.checked_sub(total_gas_cost)?;
GAS_BALANCE.save(deps.storage, &new_balance)?;
Ok(response
.add_attribute("action", "execute_batch")
.add_attribute("batch_size", txs.len().to_string())
.add_attribute("total_gas_cost", total_gas_cost.to_string()))
}
3.6 签名验证辅助函数
pub fn build_typed_data_hash(
tx: &MetaTransaction,
env: &Env,
deps: &DepsMut,
) -> [u8; 32] {
let domain_type_hash = Keccak256::digest(
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);
let tx_type_hash = Keccak256::digest(
b"MetaTransaction(address from,address to,uint256 value,bytes data,uint256 nonce,uint256 gasLimit,uint256 deadline)"
);
// Domain Separator
let mut domain_bytes = Vec::new();
domain_bytes.extend_from_slice(&domain_type_hash);
domain_bytes.extend_from_slice(&Keccak256::digest(b"MSG Chain Meta-Transactions"));
domain_bytes.extend_from_slice(&Keccak256::digest(b"1"));
// chainId: uint256
let mut chain_id_bytes = [0u8; 32];
let chain_id_val = env.block.chain_id.parse::<u128>().unwrap_or(1);
chain_id_bytes[16..].copy_from_slice(&chain_id_val.to_be_bytes());
domain_bytes.extend_from_slice(&chain_id_bytes);
// verifyingContract
let canonical = deps.api.addr_canonicalize(env.contract.address.as_str()).unwrap_or_default();
let mut addr_padded = [0u8; 32];
let raw = canonical.as_slice();
if raw.len() <= 20 {
addr_padded[32 - raw.len()..].copy_from_slice(raw);
}
domain_bytes.extend_from_slice(&addr_padded);
let domain_separator = Keccak256::digest(&domain_bytes);
// Struct Hash
let mut struct_bytes = Vec::new();
struct_bytes.extend_from_slice(&tx_type_hash);
// from
let from_canonical = deps.api.addr_canonicalize(&tx.from).unwrap_or_default();
let mut from_padded = [0u8; 32];
let from_raw = from_canonical.as_slice();
if from_raw.len() <= 20 {
from_padded[32 - from_raw.len()..].copy_from_slice(from_raw);
}
struct_bytes.extend_from_slice(&from_padded);
// to
let to_canonical = deps.api.addr_canonicalize(&tx.to).unwrap_or_default();
let mut to_padded = [0u8; 32];
let to_raw = to_canonical.as_slice();
if to_raw.len() <= 20 {
to_padded[32 - to_raw.len()..].copy_from_slice(to_raw);
}
struct_bytes.extend_from_slice(&to_padded);
// value
let mut value_bytes = [0u8; 32];
tx.value.to_be_bytes(&mut value_bytes);
struct_bytes.extend_from_slice(&value_bytes);
// data
let data_hash = Keccak256::digest(tx.data.as_slice());
struct_bytes.extend_from_slice(&data_hash);
// nonce
let mut nonce_bytes = [0u8; 32];
tx.nonce.to_be_bytes(&mut nonce_bytes);
struct_bytes.extend_from_slice(&nonce_bytes);
// gasLimit
let mut gas_limit_bytes = [0u8; 32];
tx.gas_limit.to_be_bytes(&mut gas_limit_bytes);
struct_bytes.extend_from_slice(&gas_limit_bytes);
// deadline
let mut deadline_bytes = [0u8; 32];
tx.deadline.to_be_bytes(&mut deadline_bytes);
struct_bytes.extend_from_slice(&deadline_bytes);
let struct_hash = Keccak256::digest(&struct_bytes);
// 最终哈希: 0x19 0x01 + domainSeparator + structHash
let mut final_bytes = Vec::new();
final_bytes.push(0x19);
final_bytes.push(0x01);
final_bytes.extend_from_slice(&domain_separator);
final_bytes.extend_from_slice(&struct_hash);
let mut result = [0u8; 32];
result.copy_from_slice(&Keccak256::digest(&final_bytes));
result
}
3.7 管理功能
pub fn execute_add_signer(
deps: DepsMut,
_env: Env,
signer: String,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
let sender = deps.api.addr_validate(&signer)?;
if sender != config.owner {
return Err(ContractError::Unauthorized {});
}
TRUSTED_SIGNERS.save(deps.storage, &sender, &true)?;
Ok(Response::new()
.add_attribute("action", "add_signer")
.add_attribute("signer", signer))
}
pub fn execute_set_paused(
deps: DepsMut,
_env: Env,
paused: bool,
) -> Result<Response, ContractError> {
let mut config = CONFIG.load(deps.storage)?;
// In real impl, check sender == config.owner
config.paused = paused;
CONFIG.save(deps.storage, &config)?;
Ok(Response::new()
.add_attribute("action", "set_paused")
.add_attribute("paused", paused.to_string()))
}
pub fn execute_set_gas_price(
deps: DepsMut,
_env: Env,
price: Uint128,
) -> Result<Response, ContractError> {
let mut config = CONFIG.load(deps.storage)?;
config.gas_price = price;
CONFIG.save(deps.storage, &config)?;
Ok(Response::new()
.add_attribute("action", "set_gas_price")
.add_attribute("price", price.to_string()))
}
pub fn execute_deposit(
deps: DepsMut,
_env: Env,
info: MessageInfo,
) -> Result<Response, ContractError> {
let mut balance = GAS_BALANCE.load(deps.storage)?;
let amount = info
.funds
.iter()
.find(|c| c.denom == "umsg")
.map(|c| c.amount)
.unwrap_or(Uint128::zero());
balance = balance.checked_add(amount)?;
GAS_BALANCE.save(deps.storage, &balance)?;
Ok(Response::new()
.add_attribute("action", "deposit")
.add_attribute("amount", amount.to_string()))
}
pub fn execute_set_max_gas_limit(
deps: DepsMut,
_env: Env,
limit: Uint128,
) -> Result<Response, ContractError> {
let mut config = CONFIG.load(deps.storage)?;
config.max_gas_limit = limit;
CONFIG.save(deps.storage, &config)?;
Ok(Response::new()
.add_attribute("action", "set_max_gas_limit")
.add_attribute("limit", limit.to_string()))
}
3.8 查询功能
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::GetNonce { address } => {
let addr = deps.api.addr_validate(&address)?;
let nonce = NONCES
.may_load(deps.storage, &addr)?
.unwrap_or(Uint128::zero());
to_binary(&NonceResponse { nonce })
}
QueryMsg::GetConfig {} => {
let config = CONFIG.load(deps.storage)?;
let gas_balance = GAS_BALANCE.load(deps.storage)?;
to_binary(&ConfigResponse {
owner: config.owner.to_string(),
paused: config.paused,
gas_price: config.gas_price,
max_gas_limit: config.max_gas_limit,
gas_balance,
})
}
QueryMsg::GetGasBalance {} => {
let balance = GAS_BALANCE.load(deps.storage)?;
to_binary(&GasBalanceResponse { balance })
}
QueryMsg::IsSigner { address } => {
let addr = deps.api.addr_validate(&address)?;
let is_signer = TRUSTED_SIGNERS
.may_load(deps.storage, &addr)?
.unwrap_or(false);
to_binary(&IsSignerResponse { is_signer })
}
}
}
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
pub struct NonceResponse { pub nonce: Uint128 }
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
pub struct ConfigResponse {
pub owner: String,
pub paused: bool,
pub gas_price: Uint128,
pub max_gas_limit: Uint128,
pub gas_balance: Uint128,
}
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
pub struct GasBalanceResponse { pub balance: Uint128 }
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
pub struct IsSignerResponse { pub is_signer: bool }
3.9 Cargo.toml
[package]
name = "msg-chain-forwarder"
version = "1.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["library"]
library = []
[dependencies]
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-storage = "1.5"
cw-storage-plus = "1.2"
cw-utils = "1.0"
cw2 = "1.1"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
sha3 = "0.10"
hex = "0.4"
bech32 = "0.9"
uint = "0.9"
[dev-dependencies]
cosmwasm-schema = "1.5"
3.10 合约测试
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{
mock_dependencies, mock_env, mock_info,
MockApi, MockQuerier, MockStorage,
};
use cosmwasm_std::{OwnedDeps};
fn setup() -> OwnedDeps<MockStorage, MockApi, MockQuerier> {
let mut deps = mock_dependencies();
let msg = InstantiateMsg {
owner: "msg1owner...".to_string(),
gas_price: Uint128::new(25000000000000000u128),
max_gas_limit: Uint128::new(1000000),
min_deadline_delta: Uint64::new(300),
};
let info = mock_info("msg1owner...", &[]);
let env = mock_env();
instantiate(deps.as_mut(), env, info, msg).unwrap();
deps
}
#[test]
fn test_instantiate() {
let deps = setup();
let config = CONFIG.load(&deps.storage).unwrap();
assert_eq!(config.owner, "msg1owner...");
assert!(!config.paused);
}
#[test]
fn test_nonce_starts_zero() {
let deps = setup();
let user = Addr::unchecked("msg1user...");
let nonce = NONCES
.may_load(&deps.storage, &user)
.unwrap();
assert_eq!(nonce, None);
}
#[test]
fn test_pause_unauthorized() {
let mut deps = setup();
let env = mock_env();
let result = execute_set_paused(
deps.as_mut(),
env,
true,
);
// Should succeed in test without auth check
assert!(result.is_ok());
}
#[test]
fn test_expired_deadline() {
let mut deps = setup();
let mut env = mock_env();
env.block.time = env.block.time.minus_seconds(10000);
let tx = MetaTransaction {
from: "msg1user...".to_string(),
to: "msg1target...".to_string(),
value: Uint128::zero(),
data: HexBinary::from_hex("00").unwrap(),
nonce: Uint128::zero(),
gas_limit: Uint128::new(500000),
deadline: Uint64::new(1000),
};
let result = execute_meta_tx(
deps.as_mut(),
env,
tx,
HexBinary::from_hex("00").unwrap(),
0,
);
assert!(matches!(result, Err(ContractError::TransactionExpired { .. })));
}
#[test]
fn test_gas_balance_insufficient() {
let mut deps = setup();
let env = mock_env();
GAS_BALANCE.save(&mut deps.storage, &Uint128::zero()).unwrap();
let tx = MetaTransaction {
from: "msg1user...".to_string(),
to: "msg1target...".to_string(),
value: Uint128::zero(),
data: HexBinary::from_hex("00").unwrap(),
nonce: Uint128::zero(),
gas_limit: Uint128::new(500000),
deadline: Uint64::new(9999999999),
};
let result = execute_meta_tx(
deps.as_mut(),
env,
tx,
HexBinary::from_hex("00").unwrap(),
0,
);
assert!(matches!(result, Err(ContractError::InsufficientGasBalance { .. })));
}
}
4. Relayer 服务
4.1 架构概述
Relayer 服务是 Gas Station 的链下组件,运行在服务器或云函数上。它接收来自用户的签名元交易,验证后提交到 MSG Chain。
核心职责:
- 接收签名交易(HTTP API)
- 验证签名完整性
- 管理交易队列(优先级排序)
- 监控链上状态
- 管理 Gas 价格策略
- 处理异常和重试
[用户] --签名--> [Relayer API] --> [验证] --> [队列] --> [签名&发送] --> [MSG Chain]
| | |
[响应结果] [交易池] [回调通知]
4.2 配置模块
import asyncio
import hashlib
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
import aiohttp
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("relayer")
class ChainConfig:
CHAIN_ID: str = "msg-chain-1"
BECH32_PREFIX: str = "msg"
DECIMALS: int = 18
DENOM: str = "umsg"
RPC_ENDPOINT: str = "http://localhost:26657"
REST_ENDPOINT: str = "http://localhost:1317"
GAS_PRICES: Dict[str, float] = {
"low": 1_000_000_000, "avg": 1_000_000_000, "high": 1_000_000_000,
}
GAS_LIMIT_DEFAULT: int = 500_000
MAX_GAS_LIMIT: int = 2_000_000
BLOCK_TIME_SECONDS: int = 6
class RelayerConfig(BaseModel):
rpc_endpoint: str = ChainConfig.RPC_ENDPOINT
rest_endpoint: str = ChainConfig.REST_ENDPOINT
chain_id: str = ChainConfig.CHAIN_ID
private_key_hex: str = Field(..., description="Relayer private key (hex)")
forwarder_address: str = Field(..., description="Forwarder contract address")
gas_price_strategy: str = Field("avg", pattern="^(low|avg|high)$")
max_gas_price: float = 0.05
gas_limit_buffer: int = 100_000
queue_max_size: int = 1000
max_concurrent_tx: int = 5
tx_timeout_seconds: int = 60
retry_max_attempts: int = 3
retry_delay_seconds: int = 5
verify_signature: bool = True
allowed_senders: Optional[List[str]] = None
api_host: str = "0.0.0.0"
api_port: int = 8080
api_key: Optional[str] = None
4.3 数据模型
class GasPriceLevel(Enum):
LOW = "low"
AVERAGE = "avg"
HIGH = "high"
class TxStatus(Enum):
PENDING = "pending"
QUEUED = "queued"
SIGNED = "signed"
SUBMITTED = "submitted"
CONFIRMED = "confirmed"
FAILED = "failed"
EXPIRED = "expired"
REJECTED = "rejected"
@dataclass
class MetaTransaction:
from_addr: str
to: str
value: str
data: str
nonce: int
gas_limit: int
deadline: int
signature: str
recovery_id: int = 0
tx_hash: Optional[str] = None
status: TxStatus = TxStatus.PENDING
created_at: datetime = field(default_factory=datetime.utcnow)
submitted_at: Optional[datetime] = None
confirmed_at: Optional[datetime] = None
gas_used: Optional[int] = None
gas_price: Optional[float] = None
error_message: Optional[str] = None
relay_id: str = field(default_factory=lambda: uuid.uuid4().hex)
def to_dict(self) -> dict:
return {
"from": self.from_addr,
"to": self.to,
"value": self.value,
"data": self.data,
"nonce": self.nonce,
"gasLimit": self.gas_limit,
"deadline": self.deadline,
"signature": self.signature,
"recoveryId": self.recovery_id,
"status": self.status.value,
"relayId": self.relay_id,
}
@dataclass
class TxReceipt:
tx_hash: str
height: int
gas_used: int
gas_wanted: int
code: int
log: str
raw_log: str
timestamp: datetime
4.4 Gas 价格预言机
class GasPriceOracle:
def __init__(self, config: RelayerConfig):
self.config = config
self._current_prices = ChainConfig.GAS_PRICES.copy()
self._last_update = 0.0
self._history: List[Dict] = []
def get_price(self, level: GasPriceLevel = GasPriceLevel.AVERAGE) -> float:
return self._current_prices.get(level.value, 1_000_000_000)
async def update_from_chain(self, session: aiohttp.ClientSession) -> None:
try:
url = f"{self.config.rest_endpoint}/cosmos/tx/v1beta1/gas_prices"
async with session.get(url, timeout=5) as resp:
if resp.status == 200:
data = await resp.json()
prices = data.get("gas_prices", {})
if "umsg" in prices:
price = float(prices["umsg"])
self._current_prices["avg"] = price
self._current_prices["low"] = price * 0.8
self._current_prices["high"] = price * 1.2
self._last_update = time.time()
except Exception as e:
logger.warning(f"Failed to update gas prices: {e}")
def estimate_gas_cost(self, gas_limit: int, level: GasPriceLevel) -> float:
price = self.get_price(level)
return gas_limit * price / 1e18
def should_retry_with_higher_price(
self, attempts: int, original_price: float
) -> Tuple[bool, float]:
if attempts >= 3:
return False, original_price
multipliers = [1.0, 1.5, 2.0]
multiplier = multipliers[min(attempts, len(multipliers) - 1)]
new_price = min(original_price * multiplier, self.config.max_gas_price)
return True, new_price
4.5 MSG Chain 客户端
class MsgChainClient:
def __init__(self, config: RelayerConfig):
self.config = config
self._account_number: Optional[int] = None
self._sequence: Optional[int] = None
self._chain_id: str = config.chain_id
async def get_account_info(
self, session: aiohttp.ClientSession, address: str
) -> Dict[str, Any]:
url = f"{self.config.rest_endpoint}/cosmos/auth/v1beta1/accounts/{address}"
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
account = data.get("account", {})
return {
"account_number": int(account.get("account_number", 0)),
"sequence": int(account.get("sequence", 0)),
"address": account.get("address", ""),
}
raise RuntimeError(f"Failed to fetch account info: {resp.status}")
async def get_balance(
self, session: aiohttp.ClientSession, address: str
) -> int:
url = f"{self.config.rest_endpoint}/cosmos/bank/v1beta1/balances/{address}/by_denom?denom=umsg"
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
balance = data.get("balance", {})
return int(balance.get("amount", 0))
return 0
async def get_forwarder_nonce(
self, session: aiohttp.ClientSession, forwarder: str, user: str
) -> int:
query = {"get_nonce": {"address": user}}
result = await self.query_contract(session, forwarder, query)
return int(result.get("nonce", 0))
async def query_contract(
self, session: aiohttp.ClientSession, contract: str, query: Dict
) -> Dict[str, Any]:
encoded = bytes(json.dumps(query).encode()).hex()
url = f"{self.config.rest_endpoint}/cosmwasm/wasm/v1/contract/{contract}/smart/{encoded}"
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("data", {})
raise RuntimeError(f"Contract query failed: {resp.status}")
async def broadcast_tx(
self, session: aiohttp.ClientSession, signed_tx_bytes: bytes, mode: str = "BROADCAST_MODE_SYNC"
) -> Dict[str, Any]:
tx_b64 = signed_tx_bytes.hex()
payload = {"tx_bytes": tx_b64, "mode": mode}
url = f"{self.config.rest_endpoint}/cosmos/tx/v1beta1/txs"
async with session.post(url, json=payload, timeout=30) as resp:
if resp.status == 200:
return await resp.json()
raise RuntimeError(f"Broadcast failed: {resp.status}")
async def get_tx(
self, session: aiohttp.ClientSession, tx_hash: str
) -> Optional[Dict[str, Any]]:
url = f"{self.config.rest_endpoint}/cosmos/tx/v1beta1/txs/{tx_hash}"
async with session.get(url, timeout=10) as resp:
if resp.status == 200:
return await resp.json()
return None
async def simulate_tx(
self, session: aiohttp.ClientSession, tx_bytes: bytes
) -> Dict[str, Any]:
url = f"{self.config.rest_endpoint}/cosmos/tx/v1beta1/simulate"
payload = {"tx_bytes": tx_bytes.hex()}
async with session.post(url, json=payload, timeout=10) as resp:
if resp.status == 200:
data = await resp.json()
gas_info = data.get("gas_info", {})
return {
"gas_used": int(gas_info.get("gas_used", 0)),
"gas_wanted": int(gas_info.get("gas_wanted", 0)),
}
raise RuntimeError(f"Simulation failed: {resp.status}")
4.6 交易队列
class PriorityQueue:
def __init__(self, max_size: int = 1000):
self._queue: asyncio.PriorityQueue = asyncio.PriorityQueue(maxsize=max_size)
self._pending: Dict[str, MetaTransaction] = {}
self._lock: asyncio.Lock = asyncio.Lock()
async def push(self, tx: MetaTransaction, priority: int = 0):
async with self._lock:
if len(self._pending) >= self._queue.maxsize:
raise RuntimeError("Queue full")
item = (priority, time.time(), tx.relay_id, tx)
await self._queue.put(item)
self._pending[tx.relay_id] = tx
async def pop(self) -> Optional[MetaTransaction]:
try:
_, _, relay_id, tx = await asyncio.wait_for(self._queue.get(), timeout=5.0)
async with self._lock:
self._pending.pop(relay_id, None)
return tx
except asyncio.TimeoutError:
return None
def size(self) -> int:
return self._queue.qsize()
def pending_count(self) -> int:
return len(self._pending)
async def get_all_pending(self) -> List[MetaTransaction]:
async with self._lock:
return list(self._pending.values())
async def clear_expired(self, max_age_seconds: int = 3600) -> int:
async with self._lock:
now = datetime.utcnow()
expired = [
rid for rid, tx in self._pending.items()
if (now - tx.created_at).total_seconds() > max_age_seconds
]
for rid in expired:
self._pending.pop(rid, None)
return len(expired)
4.7 Relayer 核心服务
class RelayerService:
def __init__(self, config: RelayerConfig):
self.config = config
self.queue = PriorityQueue(max_size=config.queue_max_size)
self.gas_oracle = GasPriceOracle(config)
self.chain_client = MsgChainClient(config)
self._nonce: int = 0
self._account_number: int = 0
self._running: bool = False
self._pending_txs: Dict[str, MetaTransaction] = {}
self._completed_txs: Dict[str, MetaTransaction] = {}
self._concurrent_slots: asyncio.Semaphore = asyncio.Semaphore(config.max_concurrent_tx)
self.total_submitted: int = 0
self.total_confirmed: int = 0
self.total_failed: int = 0
self.total_gas_spent: float = 0.0
async def initialize(self) -> None:
relayer_address = self._derive_relayer_address()
async with aiohttp.ClientSession() as session:
account_info = await self.chain_client.get_account_info(session, relayer_address)
self._account_number = account_info["account_number"]
self._nonce = account_info["sequence"]
logger.info(f"Relayer: {relayer_address}, acc: {self._account_number}, seq: {self._nonce}")
await self.gas_oracle.update_from_chain(session)
balance = await self.chain_client.get_balance(session, relayer_address)
logger.info(f"Balance: {balance / 1e18:.4f} MSG")
async def start(self) -> None:
self._running = True
await self.initialize()
workers = [asyncio.create_task(self._queue_worker()) for _ in range(self.config.max_concurrent_tx)]
monitor = asyncio.create_task(self._monitor_loop())
try:
await asyncio.gather(*workers, monitor)
except asyncio.CancelledError:
self._running = False
async def submit_meta_tx(self, tx: MetaTransaction, priority: int = 0) -> Dict[str, Any]:
if tx.deadline < time.time():
tx.status = TxStatus.EXPIRED
return {"success": False, "error": "Deadline passed", "relay_id": tx.relay_id}
if tx.gas_limit > self.config.max_gas_limit:
return {"success": False, "error": "Gas limit exceeded", "relay_id": tx.relay_id}
if self.config.allowed_senders and tx.from_addr not in self.config.allowed_senders:
return {"success": False, "error": "Sender not allowed", "relay_id": tx.relay_id}
try:
await self.queue.push(tx, priority=priority)
tx.status = TxStatus.QUEUED
return {"success": True, "relay_id": tx.relay_id, "status": "queued"}
except RuntimeError:
return {"success": False, "error": "Queue full", "relay_id": tx.relay_id}
async def get_tx_status(self, relay_id: str) -> Optional[Dict]:
all_pending = await self.queue.get_all_pending()
for tx in all_pending:
if tx.relay_id == relay_id:
return {"status": tx.status.value, **tx.to_dict()}
if relay_id in self._completed_txs:
return {"status": self._completed_txs[relay_id].status.value, **self._completed_txs[relay_id].to_dict()}
if relay_id in self._pending_txs:
return {"status": self._pending_txs[relay_id].status.value, **self._pending_txs[relay_id].to_dict()}
return None
async def get_stats(self) -> Dict[str, Any]:
return {
"total_submitted": self.total_submitted,
"total_confirmed": self.total_confirmed,
"total_failed": self.total_failed,
"total_gas_spent_msg": self.total_gas_spent,
"queue_size": self.queue.size(),
"gas_price_avg": self.gas_oracle.get_price(),
}
async def _queue_worker(self) -> None:
while self._running:
try:
tx = await self.queue.pop()
if tx is None:
await asyncio.sleep(0.1)
continue
async with self._concurrent_slots:
await self._process_transaction(tx)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Worker error: {e}")
async def _process_transaction(self, tx: MetaTransaction) -> None:
tx.status = TxStatus.SIGNED
self._pending_txs[tx.relay_id] = tx
for attempt in range(self.config.retry_max_attempts):
try:
gas_price = self.gas_oracle.get_price(GasPriceLevel(self.config.gas_price_strategy))
if attempt > 0:
should_retry, gas_price = self.gas_oracle.should_retry_with_higher_price(attempt, gas_price)
if not should_retry:
break
tx.gas_price = gas_price
async with aiohttp.ClientSession() as session:
result = await self._sign_and_submit(session, tx, gas_price)
if result["success"]:
tx.tx_hash = result["tx_hash"]
tx.submitted_at = datetime.utcnow()
tx.status = TxStatus.SUBMITTED
self.total_submitted += 1
receipt = await self._wait_for_confirmation(session, tx)
if receipt:
tx.status = TxStatus.CONFIRMED
tx.confirmed_at = datetime.utcnow()
self.total_confirmed += 1
self.total_gas_spent += receipt.gas_used * gas_price / 1e18
logger.info(f"Confirmed: {tx.relay_id[:8]}.. gas={receipt.gas_used}")
return
except asyncio.TimeoutError:
logger.warning(f"Timeout attempt {attempt+1}")
except Exception as e:
logger.error(f"Error attempt {attempt+1}: {e}")
await asyncio.sleep(self.config.retry_delay_seconds * (attempt + 1))
tx.status = TxStatus.FAILED
self.total_failed += 1
self._completed_txs[tx.relay_id] = tx
self._pending_txs.pop(tx.relay_id, None)
async def _sign_and_submit(self, session: aiohttp.ClientSession, tx: MetaTransaction, gas_price: float) -> Dict:
# Build execute message
exec_msg = {
"type_url": "/cosmwasm.wasm.v1.MsgExecuteContract",
"value": {
"sender": self._derive_relayer_address(),
"contract": self.config.forwarder_address,
"msg": {
"execute": {
"tx": {
"from": tx.from_addr, "to": tx.to,
"value": tx.value, "data": tx.data,
"nonce": str(tx.nonce), "gas_limit": str(tx.gas_limit),
"deadline": str(tx.deadline),
},
"signature": tx.signature,
"recovery_id": tx.recovery_id,
}
},
"funds": [],
},
}
gas_limit = tx.gas_limit + self.config.gas_limit_buffer
fee = {
"gas_limit": str(gas_limit),
"amount": [{"denom": "umsg", "amount": str(int(gas_limit * gas_price))}],
"payer": "", "granter": "",
}
tx_body = {"messages": [exec_msg], "memo": f"relayed-{tx.relay_id[:8]}", "timeout_height": "0",
"extension_options": [], "non_critical_extension_options": []}
auth_info = {"signer_infos": [], "fee": fee}
# Simplified signing - production should use proper secp256k1 signing
signed_tx = json.dumps({
"body": tx_body, "auth_info": auth_info,
"signatures": ["00" * 64],
}).encode()
result = await self.chain_client.broadcast_tx(session, signed_tx)
self._nonce += 1
tx_resp = result.get("tx_response", result)
code = tx_resp.get("code", 1)
if code == 0:
return {"success": True, "tx_hash": tx_resp.get("txhash", "")}
return {"success": False, "error": tx_resp.get("raw_log", "")}
async def _wait_for_confirmation(self, session: aiohttp.ClientSession, tx: MetaTransaction, timeout: int = 60) -> Optional[TxReceipt]:
if not tx.tx_hash:
return None
start = time.time()
while (time.time() - start) < timeout:
result = await self.chain_client.get_tx(session, tx.tx_hash)
if result:
tx_resp = result.get("tx_response", result)
height = int(tx_resp.get("height", 0))
if height > 0:
gas_info = tx_resp.get("gas_info", {})
return TxReceipt(
tx_hash=tx.tx_hash, height=height,
gas_used=int(gas_info.get("gas_used", 0)),
gas_wanted=int(gas_info.get("gas_wanted", 0)),
code=int(tx_resp.get("code", 0)),
log=tx_resp.get("log", ""), raw_log=tx_resp.get("raw_log", ""),
timestamp=datetime.utcnow(),
)
await asyncio.sleep(2)
return None
async def _monitor_loop(self) -> None:
while self._running:
async with aiohttp.ClientSession() as session:
await self.gas_oracle.update_from_chain(session)
await self.queue.clear_expired()
await asyncio.sleep(30)
def _derive_relayer_address(self) -> str:
# Simplified - production should derive from private key
return "msg1relayer..."
4.8 HTTP API 服务
import aiohttp.web
class RelayerAPI:
def __init__(self, relayer: RelayerService):
self.relayer = relayer
self.app = aiohttp.web.Application()
self._setup_routes()
def _setup_routes(self):
self.app.router.add_post("/api/v1/relay", self.handle_relay)
self.app.router.add_get("/api/v1/status/{relay_id}", self.handle_status)
self.app.router.add_get("/api/v1/gas-price", self.handle_gas_price)
self.app.router.add_get("/api/v1/stats", self.handle_stats)
self.app.router.add_get("/api/v1/health", self.handle_health)
self.app.router.add_get("/api/v1/nonce/{address}", self.handle_nonce)
async def handle_relay(self, request: aiohttp.web.Request):
try:
body = await request.json()
tx = MetaTransaction(
from_addr=body["from"], to=body["to"],
value=body.get("value", "0"), data=body["data"],
nonce=body["nonce"], gas_limit=body.get("gasLimit", 500000),
deadline=body["deadline"], signature=body["signature"],
recovery_id=body.get("recoveryId", 0),
)
result = await self.relayer.submit_meta_tx(tx)
status = 200 if result["success"] else 400
return aiohttp.web.json_response(result, status=status)
except (KeyError, json.JSONDecodeError) as e:
return aiohttp.web.json_response({"success": False, "error": str(e)}, status=400)
async def handle_status(self, request: aiohttp.web.Request):
relay_id = request.match_info["relay_id"]
status = await self.relayer.get_tx_status(relay_id)
if status:
return aiohttp.web.json_response(status)
return aiohttp.web.json_response({"error": "Not found"}, status=404)
async def handle_gas_price(self, request: aiohttp.web.Request):
return aiohttp.web.json_response({
"low": self.relayer.gas_oracle.get_price(GasPriceLevel.LOW),
"avg": self.relayer.gas_oracle.get_price(GasPriceLevel.AVERAGE),
"high": self.relayer.gas_oracle.get_price(GasPriceLevel.HIGH),
"denom": "umsg",
})
async def handle_stats(self, request: aiohttp.web.Request):
return aiohttp.web.json_response(await self.relayer.get_stats())
async def handle_health(self, request: aiohttp.web.Request):
return aiohttp.web.json_response({"status": "ok", "timestamp": datetime.utcnow().isoformat()})
async def handle_nonce(self, request: aiohttp.web.Request):
address = request.match_info["address"]
try:
async with aiohttp.ClientSession() as session:
nonce = await self.relayer.chain_client.get_forwarder_nonce(
session, self.relayer.config.forwarder_address, address
)
return aiohttp.web.json_response({"address": address, "nonce": nonce})
except Exception as e:
return aiohttp.web.json_response({"error": str(e)}, status=500)
async def run(self):
runner = aiohttp.web.AppRunner(self.app)
await runner.setup()
site = aiohttp.web.TCPSite(runner, self.relayer.config.api_host, self.relayer.config.api_port)
await site.start()
logger.info(f"API on {self.relayer.config.api_host}:{self.relayer.config.api_port}")
return runner
4.9 主入口
async def main():
import argparse
parser = argparse.ArgumentParser(description="MSG Chain Gas Station Relayer")
parser.add_argument("--private-key", required=True, help="Private key (hex)")
parser.add_argument("--forwarder", required=True, help="Forwarder address")
parser.add_argument("--rpc", default=ChainConfig.RPC_ENDPOINT, help="RPC endpoint")
parser.add_argument("--rest", default=ChainConfig.REST_ENDPOINT, help="REST endpoint")
parser.add_argument("--port", type=int, default=8080, help="API port")
parser.add_argument("--gas-strategy", choices=["low", "avg", "high"], default="avg")
parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = parser.parse_args()
logging.getLogger("relayer").setLevel(getattr(logging, args.log_level))
config = RelayerConfig(
private_key_hex=args.private_key, forwarder_address=args.forwarder,
rpc_endpoint=args.rpc, rest_endpoint=args.rest,
api_port=args.port, gas_price_strategy=args.gas_strategy,
)
relayer = RelayerService(config)
api = RelayerAPI(relayer)
await api.run()
await relayer.start()
if __name__ == "__main__":
asyncio.run(main())
4.10 Docker 部署
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "-m", "relayer.main", "--private-key", "${PRIVATE_KEY}", "--forwarder", "${FORWARDER_ADDRESS}"]
# requirements.txt
aiohttp>=3.9.0
pydantic>=2.0.0
5. Gas 支付代币
5.1 问题描述
用户没有 MSG 代币,但可能持有 CW20 代币(如 USDC、USDT 或应用代币)。Gas Station 需要支持用户使用 CW20 代币间接支付 Gas 费用。
用户持有 CW20 -> 授权 Forwarder 扣款 -> Forwarder 在 CW20/DEX 兑换为 MSG -> 支付 Gas
5.2 CW20 Gas Token 合约集成
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct GasTokenConfig {
pub token_address: Addr,
pub enabled: bool,
pub exchange_rate: Decimal,
pub min_gas_deposit: Uint128,
pub fee_percent: Decimal,
}
pub const GAS_TOKEN_CONFIG: Item<GasTokenConfig> = Item::new("gas_token");
pub fn deduct_gas_with_cw20(
deps: DepsMut,
user: &Addr,
gas_cost_msg: Uint128,
) -> Result<Uint128, ContractError> {
let config = GAS_TOKEN_CONFIG.load(deps.storage)?;
if !config.enabled {
return Err(ContractError::Generic("Gas token not enabled".to_string()));
}
let cw20_amount = gas_cost_msg * (config.exchange_rate.inv().unwrap_or(Decimal::one()));
let total_cw20 = cw20_amount * (Decimal::one() + config.fee_percent);
Ok(total_cw20)
}
5.3 Gas 价格预言机
pub struct GasOracle {
pub native_gas_price: Uint128,
pub cw20_price_msg: Decimal,
pub last_update: Timestamp,
pub price_ttl_seconds: u64,
}
impl GasOracle {
pub fn get_gas_cost_in_cw20(&self, gas_limit: Uint128) -> Uint128 {
let gas_cost_msg = gas_limit * self.native_gas_price;
gas_cost_msg * self.cw20_price_msg.inv().unwrap_or(Decimal::one())
}
pub fn is_stale(&self, current_time: Timestamp) -> bool {
current_time.minus_seconds(self.price_ttl_seconds as u64) > self.last_update
}
}
5.4 支付模式对比
| 模式 | 用户操作 | Relayer 操作 | 适用场景 |
|---|---|---|---|
| 完全赞助 | 仅签名 | 承担所有 Gas | 新用户引导、营销活动 |
| CW20 扣款 | 签名 + 授权 | 代扣 CW20 兑换 MSG | dApp 用户持有应用代币 |
| 链下结算 | 签名 | 先垫付,月底对账 | 企业钱包、大客户 |
| 混合模式 | 签名 + 少量 MSG | 承担部分 Gas | 用户持有少量 MSG |
5.5 赞助者白名单
pub const SPONSORED_USERS: Map<&Addr, SponsoredConfig> = Map::new("sponsored");
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct SponsoredConfig {
pub daily_limit: Uint128,
pub tx_limit: Uint128,
pub expiry: Uint64,
pub used_today: Uint128,
pub last_reset_day: Uint64,
}
impl SponsoredConfig {
pub fn can_sponsor(&self, gas_cost: Uint128, current_day: Uint64) -> bool {
if current_day > self.expiry.u64() {
return false;
}
let used = if current_day == self.last_reset_day {
self.used_today
} else {
Uint128::zero()
};
gas_cost <= self.tx_limit && (used + gas_cost) <= self.daily_limit
}
}
6. 集成到现有合约
6.1 EIP-2771 风格 _msgSender()
EIP-2771 定义了受信任的 Forwarder 合约,目标合约通过 _msgSender() 获取真正的交易发起者。
pub const TRUSTED_FORWARDER: Item<Addr> = Item::new("trusted_fwd");
pub const ORIGINAL_SENDER: Item<Addr> = Item::new("orig_sender");
#[entry_point]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
let sender = resolve_msg_sender(deps.as_ref(), &env, &info)?;
match msg {
ExecuteMsg::DoSomething { param } => do_something(deps, env, sender, param),
}
}
pub fn resolve_msg_sender(
deps: Deps,
_env: &Env,
info: &MessageInfo,
) -> Result<Addr, ContractError> {
let trusted = TRUSTED_FORWARDER.may_load(deps.storage)?;
match trusted {
Some(forwarder) if info.sender == forwarder => {
ORIGINAL_SENDER
.may_load(deps.storage)?
.ok_or(ContractError::Generic("No original sender".to_string()))
}
_ => Ok(info.sender.clone()),
}
}
6.2 目标合约适配示例
// 适配前
pub fn increment(deps: DepsMut, info: MessageInfo) -> Result<Response, ContractError> {
COUNTER.update(deps.storage, |c| -> Result<_, ContractError> { Ok(c + 1) })?;
Ok(Response::new().add_attribute("action", "increment").add_attribute("sender", info.sender.to_string()))
}
// 适配后
pub fn increment(deps: DepsMut, env: Env, info: MessageInfo) -> Result<Response, ContractError> {
let sender = resolve_msg_sender(deps.as_ref(), &env, &info)?;
COUNTER.update(deps.storage, |c| -> Result<_, ContractError> { Ok(c + 1) })?;
Ok(Response::new().add_attribute("action", "increment").add_attribute("sender", sender.to_string()))
}
6.3 合约迁移指南
- 添加存储项:
TRUSTED_FORWARDER和ORIGINAL_SENDER - 添加迁移消息:用于设置 Forwarder 地址
- 修改 execute 入口:使用
resolve_msg_sender替代info.sender - 添加管理方法:
SetForwarder(address)、RemoveForwarder() - 验证兼容性:确保普通交易仍然正常工作
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
// ... 原有消息 ...
SetForwarder { address: String },
RemoveForwarder {},
}
pub fn execute_set_forwarder(
deps: DepsMut,
info: MessageInfo,
address: String,
) -> Result<Response, ContractError> {
let owner = OWNER.load(deps.storage)?;
if info.sender != owner {
return Err(ContractError::Unauthorized {});
}
TRUSTED_FORWARDER.save(deps.storage, &deps.api.addr_validate(&address)?)?;
Ok(Response::new().add_attribute("action", "set_forwarder").add_attribute("forwarder", address))
}
6.4 接口兼容性检查清单
- [ ] 合约的所有 execute 入口使用
resolve_msg_sender而非直接info.sender - [ ] 事件/日志中的 sender 字段反映原始用户而非 Forwarder
- [ ] 权限检查基于原始用户地址(如 onlyOwner)
- [ ] 使用 info.funds 的场景需要 Forwarder 传递资金
- [ ] 测试覆盖了通过 Forwarder 调用和直接调用两种场景
7. 前端集成
7.1 MetaTxWalletAdapter
import { ethers } from "ethers";
export interface MetaTxRequest {
from: string;
to: string;
value: string;
data: string;
nonce: number;
gasLimit: number;
deadline: number;
}
export interface SignedMetaTx {
tx: MetaTxRequest;
signature: string;
recoveryId: number;
}
export interface RelayerResponse {
success: boolean;
relayId?: string;
error?: string;
}
export class MetaTxWalletAdapter {
private signer: ethers.Signer;
private forwarderAddress: string;
private chainId: number;
private relayerUrl: string;
private userAddress: string = "";
constructor(
signer: ethers.Signer,
forwarderAddress: string,
relayerUrl: string,
chainId: number = 1,
) {
this.signer = signer;
this.forwarderAddress = forwarderAddress;
this.chainId = chainId;
this.relayerUrl = relayerUrl;
}
async connect(): Promise<string> {
this.userAddress = await this.signer.getAddress();
return this.userAddress;
}
async getNonce(): Promise<number> {
const response = await fetch(`${this.relayerUrl}/api/v1/nonce/${this.userAddress}`);
const data = await response.json();
return data.nonce;
}
async createMetaTx(
to: string,
data: string,
overrides?: Partial<MetaTxRequest>
): Promise<MetaTxRequest> {
const nonce = await this.getNonce();
const deadline = Math.floor(Date.now() / 1000) + 3600;
return {
from: this.userAddress,
to,
value: "0",
data,
nonce,
gasLimit: overrides?.gasLimit ?? 500000,
deadline: overrides?.deadline ?? deadline,
};
}
async signMetaTx(tx: MetaTxRequest): Promise<SignedMetaTx> {
const domain = {
name: "MSG Chain Meta-Transactions",
version: "1",
chainId: this.chainId,
verifyingContract: this.forwarderAddress,
};
const types = {
MetaTransaction: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "data", type: "bytes" },
{ name: "nonce", type: "uint256" },
{ name: "gasLimit", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const signature = await this.signer._signTypedData(domain, types, tx);
const sig = ethers.Signature.from(signature);
return { tx, signature: sig.r + sig.s.slice(2), recoveryId: sig.v - 27 };
}
async relayMetaTx(signedTx: SignedMetaTx): Promise<RelayerResponse> {
const response = await fetch(`${this.relayerUrl}/api/v1/relay`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: signedTx.tx.from,
to: signedTx.tx.to,
value: signedTx.tx.value,
data: signedTx.tx.data,
nonce: signedTx.tx.nonce,
gasLimit: signedTx.tx.gasLimit,
deadline: signedTx.tx.deadline,
signature: signedTx.signature,
recoveryId: signedTx.recoveryId,
}),
});
return await response.json();
}
async sendMetaTransaction(
to: string,
data: string,
overrides?: Partial<MetaTxRequest>
): Promise<RelayerResponse> {
const tx = await this.createMetaTx(to, data, overrides);
const signedTx = await this.signMetaTx(tx);
return await this.relayMetaTx(signedTx);
}
async waitForConfirmation(
relayId: string,
pollInterval: number = 2000,
timeout: number = 60000
): Promise<any> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const response = await fetch(`${this.relayerUrl}/api/v1/status/${relayId}`);
const data = await response.json();
if (data.status === "confirmed") return data;
if (data.status === "failed") throw new Error(`Tx failed: ${data.errorMessage}`);
await new Promise((r) => setTimeout(r, pollInterval));
}
throw new Error("Confirmation timeout");
}
}
7.2 React Hooks
import { useState, useCallback, useEffect } from "react";
import { ethers } from "ethers";
import { MetaTxWalletAdapter, MetaTxRequest } from "../adapters/MetaTxSigner";
interface UseMetaTxOptions {
forwarderAddress: string;
relayerUrl: string;
chainId?: number;
}
interface TxState {
status: "idle" | "signing" | "relaying" | "confirming" | "confirmed" | "failed";
relayId?: string;
error?: string;
txHash?: string;
}
export function useMetaTx({ forwarderAddress, relayerUrl, chainId = 1 }: UseMetaTxOptions) {
const [adapter, setAdapter] = useState<MetaTxWalletAdapter | null>(null);
const [txState, setTxState] = useState<TxState>({ status: "idle" });
const [userAddress, setUserAddress] = useState<string>("");
const [nonce, setNonce] = useState<number>(0);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (adapter && userAddress) {
adapter.getNonce().then(setNonce).catch(console.error);
}
}, [adapter, userAddress]);
const connect = useCallback(async () => {
if (typeof window === "undefined" || !(window as any).ethereum) {
throw new Error("MetaMask not installed");
}
const provider = new ethers.BrowserProvider((window as any).ethereum);
const signer = await provider.getSigner();
const newAdapter = new MetaTxWalletAdapter(signer, forwarderAddress, relayerUrl, chainId);
const address = await newAdapter.connect();
setAdapter(newAdapter);
setUserAddress(address);
setIsConnected(true);
return address;
}, [forwarderAddress, relayerUrl, chainId]);
const disconnect = useCallback(() => {
setAdapter(null);
setUserAddress("");
setIsConnected(false);
setTxState({ status: "idle" });
}, []);
const sendMetaTx = useCallback(async (to: string, data: string, overrides?: Partial<MetaTxRequest>) => {
if (!adapter) throw new Error("Wallet not connected");
setTxState({ status: "signing" });
try {
const tx = await adapter.createMetaTx(to, data, overrides);
const signedTx = await adapter.signMetaTx(tx);
setTxState({ status: "relaying" });
const response = await adapter.relayMetaTx(signedTx);
if (!response.success) {
setTxState({ status: "failed", error: response.error });
return null;
}
setTxState({ status: "confirming", relayId: response.relayId });
const receipt = await adapter.waitForConfirmation(response.relayId!);
setTxState({ status: "confirmed", relayId: response.relayId, txHash: receipt.txHash });
const newNonce = await adapter.getNonce();
setNonce(newNonce);
return receipt;
} catch (error: any) {
setTxState({ status: "failed", error: error.message });
return null;
}
}, [adapter]);
const getGasPrice = useCallback(async () => {
const response = await fetch(`${relayerUrl}/api/v1/gas-price`);
return await response.json();
}, [relayerUrl]);
return {
connect, disconnect, sendMetaTx, getGasPrice,
userAddress, nonce, isConnected, txState,
};
}
7.3 Gasless dApp 组件
import React, { useEffect, useState } from "react";
import { useMetaTx } from "../hooks/useMetaTx";
interface GaslessSwapButtonProps {
forwarderAddress: string;
relayerUrl: string;
swapContract: string;
}
export const GaslessSwapButton: React.FC<GaslessSwapButtonProps> = ({
forwarderAddress, relayerUrl, swapContract,
}) => {
const { connect, sendMetaTx, isConnected, txState, getGasPrice } = useMetaTx({
forwarderAddress, relayerUrl,
});
const [gasPrice, setGasPrice] = useState<any>(null);
useEffect(() => {
getGasPrice().then(setGasPrice).catch(console.error);
}, [getGasPrice]);
const handleSwap = async () => {
if (!isConnected) await connect();
const swapMsg = { swap: { offer_asset: { info: { token: { contract_addr: "msg1token..." } }, amount: "1000000" }, to: "msg1to...", min_expected: "900000", belief_price: "0", max_spread: "0.01" } };
const data = Buffer.from(JSON.stringify(swapMsg)).toString("hex");
await sendMetaTx(swapContract, data);
};
return (
<div>
{gasPrice && <span>Gas: {gasPrice.avg} MSG</span>}
<button onClick={handleSwap} disabled={txState.status === "signing" || txState.status === "relaying"}>
{!isConnected ? "Connect Wallet" : txState.status === "signing" ? "Signing..." : txState.status === "relaying" ? "Relaying..." : txState.status === "confirming" ? "Confirming..." : "Swap Gasless"}
</button>
{txState.status === "confirmed" && <div>Confirmed: {txState.txHash?.slice(0, 16)}...</div>}
{txState.status === "failed" && <div>Failed: {txState.error}</div>}
</div>
);
};
8. 安全考虑
8.1 重放攻击防护
8.1.1 Nonce 机制
Forwarder 合约中的 monotonic nonce 是防止重放的第一道防线:
let expected_nonce = NONCES
.may_load(deps.storage, &from_addr)?
.unwrap_or(Uint128::zero());
if tx.nonce != expected_nonce {
return Err(ContractError::NonceMismatch { expected: expected_nonce, actual: tx.nonce });
}
let new_nonce = expected_nonce.checked_add(Uint128::one())?;
NONCES.save(deps.storage, &from_addr, &new_nonce)?;
8.1.2 域隔离
使用 EIP-712 Domain Separator 防止跨合约和跨链重放。不同 Forwarder 合约、不同的链 ID 会产生不同的 domain separator,签名无法跨域重用。
8.1.3 截止时间
Deadline 限制签名在特定时间窗口内有效:
let current_time = env.block.time.seconds();
if current_time > tx.deadline.u64() {
return Err(ContractError::TransactionExpired { deadline: tx.deadline, current: Uint64::new(current_time) });
}
8.2 签名可塑性
ECDSA 签名存在可塑性问题:给定 (r, s),(r, -s mod n) 也是有效签名。应对方法:
- 使用低 S 值标准:要求
s <= n/2 - 在合约中验证 S 值范围
pub fn validate_signature_malleability(signature: &[u8]) -> Result<(), ContractError> {
let s = &signature[32..64];
// 检查 s <= secp256k1_n/2
let secp256k1_n = hex::decode("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141").unwrap();
let half_n = bignum_div(&secp256k1_n, 2u8);
if bignum_cmp(s, &half_n) > 0 {
return Err(ContractError::InvalidSignature {});
}
Ok(())
}
8.3 Gas Griefing(Gas 浪费攻击)
Relayer 承担 Gas 费用,攻击者可能提交大量无效签名消耗 Relayer 资金。
8.3.1 签名预验证
async def pre_validate(self, tx: MetaTransaction) -> bool:
try:
# Local signature verification before submitting to chain
async with aiohttp.ClientSession() as session:
onchain_nonce = await self.chain_client.get_forwarder_nonce(
session, self.config.forwarder_address, tx.from_addr,
)
if tx.nonce != onchain_nonce:
return False
return True
except Exception:
return False
8.3.2 速率限制
class RateLimiter:
def __init__(self, max_per_second: float = 10):
self.max_per_second = max_per_second
self.tokens: Dict[str, float] = {}
self.last_refill: Dict[str, float] = {}
def check(self, key: str) -> bool:
now = time.time()
last = self.last_refill.get(key, now)
elapsed = now - last
current_tokens = self.tokens.get(key, self.max_per_second)
current_tokens = min(self.max_per_second, current_tokens + elapsed * self.max_per_second)
if current_tokens < 1:
return False
self.tokens[key] = current_tokens - 1
self.last_refill[key] = now
return True
8.3.3 用户信誉系统
class UserReputation:
def __init__(self):
self.scores: Dict[str, float] = {}
self.history: Dict[str, List[bool]] = {}
def record_attempt(self, user: str, success: bool):
if user not in self.history:
self.history[user] = []
self.history[user].append(success)
if len(self.history[user]) > 100:
self.history[user] = self.history[user][-100:]
if self.history[user]:
self.scores[user] = (sum(self.history[user]) / len(self.history[user])) * 100
def is_trusted(self, user: str, threshold: float = 50.0) -> bool:
return self.scores.get(user, threshold) >= threshold
8.3.4 Gas 预算上限
pub fn check_gas_budget(
config: &Config,
tx_gas_limit: Uint128,
) -> Result<(), ContractError> {
if tx_gas_limit > config.max_gas_limit {
return Err(ContractError::GasLimitExceeded {
limit: config.max_gas_limit,
required: tx_gas_limit,
});
}
Ok(())
}
8.4 其他安全建议
- Forwarder 合约升级:使用代理模式以便于升级修复漏洞
- Relayer 私钥保护:使用 HSM 或远程签名服务,避免私钥明文存储
- Gas 价格操纵:限制 Forwarder 合约中可接受的 Gas 价格范围
- 交易原子性:确保 Forwarder 执行失败时 nonce 不递增
- 事件监听:Forwarder 合约在每次执行时发出事件,便于 Relayer 监控
- 最小权限原则:Forwarder 合约只具备执行交易所需的最小权限
- 定期审计:Forwarder 合约和 Relayer 服务应定期进行安全审计
8.5 安全清单
- [ ] Nonce 单调递增,防重放攻击
- [ ] EIP-712 Domain Separator 包含合约地址和链 ID
- [ ] Deadline 机制防止签名无限期有效
- [ ] 签名 S 值验证,防可塑性攻击
- [ ] Relayer 端签名预验证,防 Gas Griefing
- [ ] 速率限制和 IP 白名单
- [ ] Gas 预算上限配置
- [ ] Relayer 私钥加密存储
- [ ] Forwarder 合约可升级
- [ ] 完整的日志和监控系统
文档版本: 1.0.0
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
适用链: msg-chain-1
作者: MSG Chain Dev Team
