Skip to content

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,
    PausableUpgradeable

Plus 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 nonReentrant

Step 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:

ProtectionMechanism
No residual allowanceforceApprove(recipient, 0)
Partial-fill refundbalanceAfter > balanceBefore + feesafeTransfer the difference to msg.sender, emit TokenRefunded
Native overpayment refundmsg.value > nativeValue + (isNative ? fee : 0) → refund, emit NativeRefunded

Owner operations

FunctionNotes
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
_authorizeUpgradeonlyOwner

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:

ChangeMotivation
require(_signer != address(0)) in initialize; reject recovered == address(0)Uninitialised-signer attack
setSigner(address)Hot rotation if the backend key leaks
SafeERC20 everywhere; forceApproveNon-conforming tokens (USDT and friends) failing silently
Allowance reset to 0 after the callNo drainable residue
Token refund on partial fillUsers were losing the unfilled remainder
Native overpayment refundSlippage cushions were being kept
Always forward exactly nativeValuePredictability
executedTransactions retiredTwo replay guards could diverge
Events on every mutationObservability
uint256[50] __gapRoom for V3 state

Deployments

Recorded in bz-backend/contract-deployment/scripts/blazpayRelayer.deployments.json. Full table with implementation addresses: contract addresses.

ChainIDProxy
Optimism100xb8Bd470f3C2610F83025D049085A64f1C7b78F14
BSC560x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6
Unichain1300xA01da2d3AbEFFbaa347B08C76EEC47169DdE72e9
Polygon1370x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Sonic1460x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
opBNB2040x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
World Chain4800x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
HyperEVM9990x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Sei13290x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Soneium18680x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Ronin20200x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Abstract27410xb8Bd470f3C2610F83025D049085A64f1C7b78F14
Robinhood46630xb8Bd470f3C2610F83025D049085A64f1C7b78F14
Base84530xb8Bd470f3C2610F83025D049085A64f1C7b78F14
ApeChain331390x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Arbitrum421610x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6
Celo422200x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Hemi431110x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Avalanche431140xb8Bd470f3C2610F83025D049085A64f1C7b78F14
Linea591440x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6
Berachain800940x7d98E59FabFBaDD5eCB61CC2cf876AA97f505531
Scroll5343520x5c23c9a42626Ade38ae1c9a3407096d4381EE6E6

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

ScenarioOutcome
TX_SIGNER leaksThe 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 leaksSerious: _authorizeUpgrade allows replacing the implementation entirely. The owner should be a multisig
Malicious targetContractRequires a valid platform signature, so the backend is the gate
ReentrancynonReentrant on the entry point
Replaynonces[msg.sender]
Token that returns false instead of revertingSafeERC20 catches it
Router that pulls less than approvedRefunded to the caller

Broader analysis: security model.

Upgrading

Procedure, validation and the storage-layout check: contracts & upgrades.

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.