Nimiq is a browser-based crypto payments ecosystem whose wallet lets users hold, send and swap NIM, BTC and stablecoins such as USDC and USDT. On Polygon, it runs its own smart contracts to power gas-free stablecoin transfers and in-wallet atomic swaps.
On September 16, 2026, Nimiq’s swap contracts on Polygon were exploited, resulting in a loss of approximately $50,463 (26,130.64 USDC, 24,332.49 USDT0 and 0.66 USDC.e) drained from Nimiq’s swap-liquidity wallet. The affected contracts had been in production for roughly two and a half to three and a half years, and Nimiq has suspended its gas-abstracted stablecoin trading while investigating the incident.
Overview
Attacker (EOA, EIP-7702 delegated): 0x2258491525C21f334c5a2dc22CE55e55023FC45D
Victim (Nimiq swap-liquidity wallet): 0x24Cb173Ae221AeA93369f34bdcF0Ddb35b436773
Vulnerable contracts:
ERC20PermitHTLCHandler(USDC): 0x0cFD862bE942846Cebad797d7c1BC6e47714959bERC20MetaHTLCHandler(USDT0/USDC.e): 0xF615bD7EA00C4Cc7F39Faad0895dB5f40891359f
Abused infrastructure:
OpenGSN RelayHub v2.2: 0x6C28AfC105e65782D9Ea6F2cA68df84C9e7d750d
OpenGSN StakeManager: 0x15C7B7CE10f3A9AE63554bCE7C54d0a818E967C7
Setup transaction: 0xb067efae73637f3564f58af7f6027afc497e81e47624b0048636085c678858c0
Exploit transaction: 0xb2ca76dfbfe571742b4b66465b777ab1e06988a8632be1631bef9654cc64d169
Analysis
A quick primer on OpenGSN v2
An OpenGSN meta-transaction involves four on-chain roles:
RelayHub – the shared, permissionless entry point. A registered relay worker submits
relayCall(relayRequest, signature, ...).Paymaster – decides whether to sponsor the call. The hub calls
paymaster.preRelayedCall()first; if it reverts, the call is rejected.Forwarder – the hub then calls
forwarder.execute(request, domainSeparator, typeHash, suffixData, signature). In the canonical OpenGSNForwarder, this is where the user’s EIP-712 signature and nonce are verified before the call is forwarded to the recipient.Recipient – the target dApp contract, which trusts the forwarder (
isTrustedForwarder) to have authenticatedrequest.from.
The critical detail is that both relayData.paymaster and relayData.forwarder are fields inside the relay request itself. RelayHub does not maintain any binding between a recipient and “its” paymaster – whichever paymaster the request names is the one the hub asks. The security model therefore places authentication in the forwarder precisely because the forwarder is the only role the recipient gets to choose; the paymaster only decides who pays for gas.
Nimiq’s combined design
Nimiq collapsed all three dApp-side roles into a single contract. BaseCombinedGsnHandler inherits IRelayRecipient, IPaymaster and IForwarder simultaneously, and isTrustedForwarder(f) returns true only for address(this). Signature verification, nonce checking and business preconditions (balance, allowance, token registration, HTLC id uniqueness) were all implemented in the handler’s paymaster hook, preRelayedCall() → verifyCallPrivate(). The forwarder hook, execute(), was written as a pure executor that assumes the paymaster already validated everything.
That assumption only holds if the hub always calls the handler’s own preRelayedCall() before the handler’s execute(). It doesn’t – the relay chooses the paymaster.
The attack, step by step
Become a relay. In the setup transaction, the attacker’s EIP-7702-delegated EOA called the OpenGSN StakeManager:
setRelayManagerOwner→stakeForRelayManagerwith the hub minimum of 1 POL and a 1,000-block unstake delay →authorizeHubByManager→RelayHub.addRelayWorkers. Anyone can do this; the total economic barrier was 1 POL (a fraction of a dollar), which is refundable anyway.Deploy an evil paymaster. A contract whose
preRelayedCall()returns("", false)unconditionally and whosegetGasAndDataLimits()returns permissive limits.Forge
open()requests. For each token (USDC on handler #1;USDT0andUSDC.eon handler #2) the attacker built aRelayRequestwithrequest.from = 0x24Cb…6773(the liquidity wallet),request.to = handler,request.data = open(id, token, fullVictimBalance, refund, recipient = precomputed CREATE2 address, hash = sha256(0x01), timeout, fee = 0),relayData.forwarder = handler,relayData.paymaster = evilPaymaster, gas price0and a 65-byte zero signature.Relay through the real hub.
RelayHub.relayCall()checked the worker, the stake and the gas limits, called the evil paymaster’spreRelayedCall()(accepted), and then calledhandler.execute(...).execute()ignored the signature, decodedrequest.dataand calledopenPrivate(victim, ...), which executedtoken.transferFrom(victim, handler, amount)against the victim’s unlimited approval. The victim’s balances went to zero and the tokens now sat inside HTLCs whose recipient was the attacker’s futureCREATE2contract.Redeem. The attacker deployed the
CREATE2recipient and called the ordinary, non-relayedredeem(id, target = attacker, secret = 0x01, fee = 0).checkRedeem()passed becausemsg.sendermatched the recorded recipient andsha256(0x01)matched the hashlock, soclosePrivate()transferred the full amount out.
Because gas price was set to 0, the RelayHub charge was 0 and the evil paymaster’s deposit was never touched. The whole exploit cost the attacker only ordinary Polygon gas.
Root cause
This is a missing authentication bug caused by a wrong trust assumption about call ordering across roles:
Authentication was placed in the wrong role. The handler verified the user’s signature and nonce only in
preRelayedCall(), its paymaster hook. In OpenGSN, the paymaster is selected per-request by the relay (relayData.paymaster), not by the recipient. Any staked relay can therefore route a request so that the handler’sexecute()runs but the handler’spreRelayedCall()never does.The forwarder entrypoint trusted its caller instead of its input.
execute()is guarded only byonlyRelayHub. That modifier proves the call came from the RelayHub, but the RelayHub is a permissionless public router – it is not an authority on whorequest.fromis.execute()explicitly discardsdomainSeparator,requestTypeHash,suffixDataandsignature, and never checksrequest.nonce, yet it moves funds out ofrequest.from. OpenGSN’s ownIRelayRecipientinterface documents that the forwarder is required to verify the sender’s signature and prevent replay; the handler declares itself as its own trusted forwarder while doing neither.Standing unlimited approvals turned it into a direct drain.
openPrivate()pulls funds withtransferFrom(request.from, ...). The liquidity wallet had granted both handlers effectively unlimited allowances so that swaps could be opened without re-approving, which meant a forgedfromwas immediately worth the wallet’s entireUSDC/USDT0/USDC.ebalance.
It is worth stressing that the blast radius was larger than the liquidity wallet. execute() has an else branch that calls closePrivate(closeRequestData, feeInformation) for redeem/refund selectors – again with no signature, no secret check, no timeout check and no recipient check. A forged relayed redeem(id, target = attacker, ...) would have paid out any open HTLC in either handler to an arbitrary address. Likewise, every user wallet that had ever left a residual allowance to these handlers was exposed through the open path. The liquidity wallet was simply the largest pot.
Vulnerable code
The following is the verified source of execute() as deployed at 0x0cFD862bE942846Cebad797d7c1BC6e47714959b (ERC20PermitHTLCHandler.sol). ERC20MetaHTLCHandler at 0xF615…359f contains the same function, differing only in using openWithApproval instead of openWithPermit:
function execute(ForwardRequest calldata request, bytes32 domainSeparator, bytes32 requestTypeHash,
bytes calldata suffixData, bytes calldata signature)
public override payable onlyRelayHub returns (bool success, bytes memory ret)
{
(request, domainSeparator, requestTypeHash, suffixData, signature); // silence unused warnings
bytes4 methodId = GsnUtils.getMethodSig(request.data);
// chainTokenFee is not needed here, so we just set it to 0
(OpenRequestData memory openRequestData, CloseRequestData memory closeRequestData,
FeeInformation memory feeInformation) = decodeRequestDataPrivate(methodId, request.from, request.data, 0);
nonces[request.from] = nonces[request.from] + 1;
if (methodId == this.open.selector || methodId == this.openWithPermit.selector) {
openPrivate(request.from, openRequestData); // transferFrom(request.from, ...)
} else {
closePrivate(closeRequestData, feeInformation); // pays out any HTLC to any target
}
success = true;
ret = "";
}The signature, domain separator and type hash are received and then thrown away. The nonce is incremented but never compared. checkOpen() – which enforces token registration, amount > 0, uniqueness of the HTLC id, and balance/allowance – is never called on this path. The only guard is onlyRelayHub, and the two sinks it reaches are:
function preRelayedCall(GsnTypes.RelayRequest calldata relayRequest, bytes calldata signature,
bytes calldata approvalData, uint256 maxPossibleGas)
external override virtual onlyRelayHub onlyToSelf(relayRequest)
returns (bytes memory context, bool revertOnRecipientRevert)
{
require(relayRequest.request.to == address(this), "Meta: illegal request.to");
require(relayRequest.request.nonce == getNonce(relayRequest.request.from), "Meta: Invalid nonce");
(...) = verifyCallPrivate(relayRequest.request, relayRequest.relayData, signature, approvalData);
// verifyCallPrivate -> verifyInternal(...) : EIP-712 digest.recover(signature) == request.from
// -> checkOpen / checkRedeem / checkRefund
...
}relayData.paymaster and relayData.forwarder are independent, relay-supplied fields. Setting forwarder = handlerand paymaster = evilPaymaster makes the hub skip the handler’s preRelayedCall() entirely and go straight into its unauthenticated execute().
The most telling detail is that the same contract already contains the correct pattern. The non-GSN fallback path, relayWithoutGsn(), runs the checks and the effects in one function:
function relayWithoutGsn(GsnTypes.RelayRequest calldata relayRequest, bytes calldata signature,
bytes calldata approvalData, address payable relay) public {
require(relayRequest.request.to == address(this), "Meta: illegal request.to");
require(relayRequest.request.nonce == getNonce(relayRequest.request.from), "Meta: Invalid nonce");
(...) = verifyCallPrivate(relayRequest.request, relayRequest.relayData, signature, approvalData); // signature + preconditions
...
nonces[relayRequest.request.from] = nonces[relayRequest.request.from] + 1;
if (methodId == this.open.selector || ...) openPrivate(relayRequest.request.from, openRequestData);
...
}The developers clearly knew how to authenticate a meta-transaction; they just split that logic across two OpenGSN callbacks under the belief that the hub would always invoke both on the same contract.
Conclusion
At its core, this incident came down to a single missing check: the contract trusted that someone else had already verified who was making a request, when in reality that verification could be skipped entirely. That one gap allowed an attacker to act on behalf of Nimiq’s liquidity wallet and walk away with around $50K in stablecoins, with almost no cost or effort. It is a reminder that even small, supporting contracts that look like simple glue for a wallet feature can put real funds at risk when a security assumption does not hold. At the time of writing, we could not find any public security audit report for Nimiq’s Polygon HTLC contracts, which leaves an open question of how a missing signature check in a fund-moving function could make its way into production. We strongly encourage every project to have its smart contracts independently audited before moving to production, and again whenever that code is changed or redeployed.

