Appearance
BlazpayRelayer contract
The per-chain settlement contract. Every EVM swap and bridge Blazpay originates passes through one function on it.
Source: bz-backend/contract-deployment/contracts/BlazpayRelayer.sol (v2)
Pattern: UUPS upgradeable, OpenZeppelin
Deployed: 22 chains, all at v2 or v2.1-raw
Signer on every chain: 0x75a8b522FC3195e3a3570F11f111AC89c0D35975
Fee on every chain: inPercentFee = 10 → 0.10%
Read v2, not finalContracts/
defi-set/finalContracts/bsc/BlazpayRelayer.sol is a frozen v1 verification artefact. Its signature check is signer == msg.sender; v2's is recovered == signer where signer is the platform address. That is a substantive behavioural difference, and v2 is what is deployed.
Inheritance
solidity
contract BlazpayRelayer is
Initializable,
OwnableUpgradeable,
UUPSUpgradeable,
ReentrancyGuardUpgradeable,
PausableUpgradeablePlus using ECDSA for bytes32 and using SafeERC20 for IERC20.
Storage
solidity
// ─── V1 storage — DO NOT REORDER ──────────────────────────────
mapping(bytes32 => bool) public executedTransactions; // DEPRECATED in v2
bool public enableFees;
uint256 public inPercentFee; // denominator 10_000 → 10 == 0.10%
uint256 public feeAmount; // flat alternative; native input only
mapping(address => uint256) public nonces;
address public signer;
// ─── V2 reserved ──────────────────────────────────────────────
uint256[50] private __gap;executedTransactions is retained purely for layout compatibility on chains upgraded from v1. It is no longer read or written — nonces alone prevents replay.
The MetaTransaction struct
solidity
struct MetaTransaction {
address targetContract; // the aggregator's router
bytes data; // router calldata
uint256 amount; // input amount
address token; // input token
bool isNative;
address recipient; // ⚠ the aggregator's SPENDER, not the end user
uint256 nativeValue;
uint256 nonce;
uint256 deadline;
}recipient is a misnomer
It holds the address that must be approved to pull tokens for the route — the spender. Nothing is ever "sent to the recipient" in the user-facing sense. Renaming it would be a storage-safe change but would break every off-chain builder, so it stays.
EIP-712 domain
solidity
EIP712Domain(string name, string version, uint256 chainId, address verifyingContract)
name = "BlazpayRelayer"
version = "1"
chainId = block.chainid
verifyingContract = address(this)Type hash:
MetaTransaction(address targetContract,bytes data,uint256 amount,address token,
bool isNative,address recipient,uint256 nativeValue,
uint256 nonce,uint256 deadline)getDomainSeparator() and getMetaTransactionHash(MetaTransaction) are both public, which makes off-chain hash verification straightforward.
executeMetaTransactionSwap
solidity
function executeMetaTransactionSwap(
MetaTransaction memory _metaTx,
bytes memory signature
) public payable whenNotPaused nonReentrantStep detail
1. Deadline. require(block.timestamp <= _metaTx.deadline). The SDK sets now + 100 seconds.
2. Nonce. require(_metaTx.nonce == nonces[msg.sender]) then nonces[msg.sender]++. Per-user, read via getNonce(address). A second concurrent trade from the same wallet fails on nonce, not on balance.
3. Signature.
solidity
address recovered = ECDSA.recover(metaTxHash, signature);
address s = signer;
require(s != address(0) && recovered == s, "BlazpayRelayer: Invalid signature");The s != address(0) guard closes the uninitialised-signer attack: ECDSA.recover can return address(0) for a malformed signature, which would otherwise match an unset slot.
4. Fee.
solidity
if (enableFees) {
if (_metaTx.isNative) {
fee = feeAmount != 0 ? feeAmount : (_metaTx.amount * inPercentFee) / 10_000;
} else if (inPercentFee != 0) {
fee = (_metaTx.amount * inPercentFee) / 10_000;
}
}Note that feeAmount (flat) is only honoured for native input. setFees enforces that at most one of the two is non-zero.
5. Fund movement. For ERC-20, three separate calls: pull amount, approve recipient for amount, and — only if there is a fee — pull fee in a second transferFrom. This is why an approval must cover amount + fee.
6. Execution. targetContract.call{value: _metaTx.nativeValue}(data). Note exactly nativeValue is forwarded, regardless of whether the swap input is ERC-20 or native. Revert data is bubbled with inline assembly so the user sees the router's real error.
7–8. Cleanup and refunds. Three protections that v1 lacked:
| Protection | Mechanism |
|---|---|
| No residual allowance | forceApprove(recipient, 0) |
| Partial-fill refund | balanceAfter > balanceBefore + fee → safeTransfer the difference to msg.sender, emit TokenRefunded |
| Native overpayment refund | msg.value > nativeValue + (isNative ? fee : 0) → refund, emit NativeRefunded |
Owner operations
| Function | Notes |
|---|---|
setSigner(address) | Hot rotation. Rejects zero. Emits SignerUpdated |
setFees(uint256 feeAmount, uint256 inPercentFee) | require(feeAmount == 0 || inPercentFee == 0). Emits FeesUpdated |
setFeeEnable(bool) | Emits FeeEnableUpdated |
withdrawEth() | Sweeps the whole native balance. Emits Withdrawal(address(0), …) |
withdrawToken(address) | Sweeps the whole token balance |
pause() / unpause() | Blocks executeMetaTransactionSwap |
_authorizeUpgrade | onlyOwner |
receive() and fallback() are both payable, so stray native transfers are accepted rather than reverting.
Events
solidity
MetaTransactionExecuted(address indexed user, address targetContract, bytes data)
SignerUpdated(address indexed oldSigner, address indexed newSigner)
FeesUpdated(uint256 feeAmount, uint256 inPercentFee)
FeeEnableUpdated(bool enableFees)
Withdrawal(address indexed token, address indexed to, uint256 amount) // token==0 for native
TokenRefunded(address indexed token, address indexed to, uint256 amount)
NativeRefunded(address indexed to, uint256 amount)What v2 changed
All storage slots preserved, so every chain upgraded in place:
| Change | Motivation |
|---|---|
require(_signer != address(0)) in initialize; reject recovered == address(0) | Uninitialised-signer attack |
setSigner(address) | Hot rotation if the backend key leaks |
SafeERC20 everywhere; forceApprove | Non-conforming tokens (USDT and friends) failing silently |
| Allowance reset to 0 after the call | No drainable residue |
| Token refund on partial fill | Users were losing the unfilled remainder |
| Native overpayment refund | Slippage cushions were being kept |
Always forward exactly nativeValue | Predictability |
executedTransactions retired | Two replay guards could diverge |
| Events on every mutation | Observability |
uint256[50] __gap | Room for V3 state |
Deployments
Recorded in bz-backend/contract-deployment/scripts/blazpayRelayer.deployments.json. Full table with implementation addresses: contract addresses.
| Chain | ID | Proxy |
|---|---|---|
| Optimism | 10 | 0xb8Bd470f3C2610F83025D049085A64f1C7b78F14 |
| BSC | 56 | 0x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6 |
| Unichain | 130 | 0xA01da2d3AbEFFbaa347B08C76EEC47169DdE72e9 |
| Polygon | 137 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Sonic | 146 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| opBNB | 204 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| World Chain | 480 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| HyperEVM | 999 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Sei | 1329 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Soneium | 1868 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Ronin | 2020 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Abstract | 2741 | 0xb8Bd470f3C2610F83025D049085A64f1C7b78F14 |
| Robinhood | 4663 | 0xb8Bd470f3C2610F83025D049085A64f1C7b78F14 |
| Base | 8453 | 0xb8Bd470f3C2610F83025D049085A64f1C7b78F14 |
| ApeChain | 33139 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Arbitrum | 42161 | 0x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6 |
| Celo | 42220 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Hemi | 43111 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Avalanche | 43114 | 0xb8Bd470f3C2610F83025D049085A64f1C7b78F14 |
| Linea | 59144 | 0x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6 |
| Berachain | 80094 | 0x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531 |
| Scroll | 534352 | 0x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6 |
Not deployed, deliberately: Ethereum mainnet (1) — gas too high, no bridged liquidity. Etherlink (42793) — skipped.
Owner and signer are the same address on every chain: 0x75a8b522FC3195e3a3570F11f111AC89c0D35975.
Threat notes
| Scenario | Outcome |
|---|---|
TX_SIGNER leaks | The attacker can make the relayer .call an arbitrary target — but funds are pulled from msg.sender, so they spend their own money. Griefing and reputational, not theft. Rotate with setSigner |
| Owner key leaks | Serious: _authorizeUpgrade allows replacing the implementation entirely. The owner should be a multisig |
Malicious targetContract | Requires a valid platform signature, so the backend is the gate |
| Reentrancy | nonReentrant on the entry point |
| Replay | nonces[msg.sender] |
| Token that returns false instead of reverting | SafeERC20 catches it |
| Router that pulls less than approved | Refunded to the caller |
Broader analysis: security model.
Upgrading
Procedure, validation and the storage-layout check: contracts & upgrades.