Findings
VRF subscription fail-open
[MEDIUM] VRF fee priced at request time drains the subscription; the "fail-closed" guarantee does not hold
acquire() does not fail closed on a dry subscription
The fee is priced on request-time tx.gasprice but the coordinator charges at fulfillment, so the self-funding subscription drains. And the real VRFCoordinatorV2_5.requestRandomWords never reverts on a dry sub, so acquire() keeps accepting requests that can never be fulfilled, burning each purchaser's non-refundable VRF fee. The in-code "fail-closed" comment (src/FWA.sol:1513-1515) describes behavior that only the test mock has.
The contract runs a self-funding VRF loop: each purchaser pays a vrfServiceFee into vrfReserve, and that reserve is pushed into the Chainlink subscription (topUpSubscription / _maybeAutoTopUp / _fundSubscription) which pays the coordinator for fulfillment gas. Two assumptions hold this design together, and neither is true against the real VRFCoordinatorV2_5.
The design rests on two operator-facing beliefs: that the fee a purchaser pays covers what the subscription will later be charged (so the sub is self-sustaining), and that if the sub runs dry, acquire() reverts (so no request goes out that can't be fulfilled). On mainnet neither holds: the fee under-collects and the sub bleeds down, and acquire() succeeds against an empty sub, firing requests that can never be fulfilled.
There are two distinct problems:
Request-time versus fulfillment-time pricing.
vrfServiceFee()(src/FWA.sol:1359) prices the fee asvrfServiceGasEstimate * tx.gasprice * (BPS + vrfServiceMarginBps) / BPS + vrfServiceFlatWei, evaluated in the request transaction. The coordinator charges the subscription at fulfillment time, using the node's gas price several blocks later plus the native-payment premium and, on L2 coordinators, an L1 data fee thattx.gaspricenever captures. When fulfillment gas exceeds request gas by more thanvrfServiceMarginBps, every request collects less than the sub is charged, and forwarding 100% of collected fees still net-drains the sub each cycle.The "fail-closed" comment is false. The comment at
src/FWA.sol:1513-1515, inside_openAcquisition, states thatrequestRandomWords"Reverts here if the subscription can't cover the request (fail-closed)". The realVRFCoordinatorV2_5.requestRandomWordsperforms no subscription-balance check and never reverts on a dry sub; it only validates the request shape and stores a commitment. That revert path exists only in the project's ownMockVRFCoordinator(test/FWA.t.sol:51), which bolts on an optionalminBalanceForRequestcheck the real contract lacks. So on mainnetacquire()succeeds against an underfunded sub, the node will not deliver a callback it cannot be paid for, and the acquisition is strandedPending. The purchaser's only recourse iscancelAcquisitionafterselectionTimeoutBlocks, which refunds the pool fee but not the non-refundable VRF service fee.
Impact
This is a protocol-wide liveness failure. The self-funding subscription can drain below the coordinator's per-fulfillment charge under ordinary gas conditions (fulfillment gas > request gas). Once it does (and against the real coordinator this holds even from a zero balance), acquire() still succeeds, so purchasers keep paying for and firing VRF requests that can never be fulfilled. Each such purchaser forfeits the non-refundable VRF service fee via a forced cancelAcquisition, and no acquisition can settle until the owner manually refunds the subscription. That halts the entire acquisition mechanic and, with it, fee distribution to depositors and FWAToken emissions.
No escrowed user backing or fees are stolen: the pool fee is refunded on cancel and the lost VRF fees are recoverable by the owner refunding the sub and resuming operations. Because the loss is bounded to VRF service fees and is owner-recoverable rather than a direct theft of backing, this is rated medium.
Affected code
The fee is priced on tx.gasprice in the request transaction:
// src/FWA.sol:1359
function vrfServiceFee() public view returns (uint256) {
return vrfServiceGasEstimate * tx.gasprice * (BPS + vrfServiceMarginBps) / BPS + vrfServiceFlatWei;
}
The _openAcquisition request carries a comment promising a fail-closed revert that the real coordinator does not perform:
// src/FWA.sol:1513
// Request randomness against the subscription; the coordinator bills the subscription (not this
// call) for the eventual fulfillment gas. Native payment is selected via extraArgs. Reverts here
// if the subscription can't cover the request (fail-closed) — see `topUpSubscription`.
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: vrfKeyHash,
subId: vrfSubId,
requestConfirmations: requestConfirmations,
callbackGasLimit: callbackGasLimit,
numWords: NUM_WORDS,
extraArgs: VRFV2PlusClient._argsToBytes(VRFV2PlusClient.ExtraArgsV1({nativePayment: true}))
})
);
The only place that revert exists is the project's own mock. The real VRFCoordinatorV2_5.requestRandomWords has no equivalent of this minBalanceForRequest guard, so the shipped suite validates a fail-closed path that does not exist on mainnet:
// test/FWA.t.sol:51 — MockVRFCoordinator's fail-closed check has NO counterpart on the real coordinator
function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req)
external
returns (uint256 requestId)
{
if (minBalanceForRequest != 0 && subNative[req.subId] < minBalanceForRequest) {
revert("VRF: insufficient balance");
}
requestId = nextRequestId++;
lastRequestId = requestId;
}
Proof of concept
test/ExploitM02VrfFailOpen.t.sol (published as a gist) defines RealisticVRFCoordinatorV2Plus, a faithful stand-in for VRFCoordinatorV2_5: requestRandomWords performs no balance check and always succeeds, payment is deducted only at fulfill time, and an underfunded sub causes fulfill to revert (modeling the node declining to deliver a callback it cannot be paid for). Test A proves acquire() succeeds against an empty sub, the request cannot be fulfilled, and after a forced cancel the purchaser is out exactly the VRF fee, with a second purchaser stranded identically (DoS). Test B shows the fee collected at 1 gwei is smaller than the coordinator's per-fulfillment charge, so even forwarding 100% of collected fees, the sub balance strictly decreases each cycle and strands an acquisition after five fulfillments.
Key assertions from test A:
// The comment at FWA.sol:1513-1515 claims requestRandomWords reverts fail-closed.
// Against the real coordinator it does NOT.
vm.prank(purchaser);
vm.txGasPrice(1 * GWEI);
uint256 reqId = pool.acquire{value: total}(0, 0);
// FAIL-OPEN: the acquisition was accepted despite the sub being unable to pay for fulfillment.
assertEq(uint256(_status(reqId)), uint256(FWA.AcquisitionStatus.Pending), "acquire accepted a request it cannot fulfill");
assertEq(coord.subNative(SUB_ID), 0, "subscription still empty after acquire");
// The node cannot deliver the callback on an underfunded sub -> the request is stranded.
vm.expectRevert(bytes("VRF: subscription cannot pay fulfillment gas"));
coord.fulfill(address(pool), reqId, uint256(keccak256("word")));
// Only recourse: cancel after the timeout, which refunds the POOL fee but NOT the VRF service fee.
vm.roll(block.number + pool.selectionTimeoutBlocks());
pool.cancelAcquisition(reqId);
uint256 netLoss = balBefore - purchaser.balance;
assertEq(netLoss, vrfFee, "purchaser is out exactly the non-refundable VRF service fee");
[PASS] test_M02_acquire_succeeds_on_dry_sub_then_request_is_stranded_and_fee_is_lost()
Logs:
acquire() succeeded against an empty subscription (comment claims fail-closed).
Fulfillment impossible -> purchaser forced to cancel and forfeits 910000000000000 wei of VRF fee.
[PASS] test_M02_self_funding_loop_net_drains_when_fulfillment_gas_exceeds_request_gas()
Logs:
VRF fee collected per request @1gwei : 910000000000000 wei
coordinator charge per fulfillment : 10000000000000000 wei
Cycle 0: sub balance drained to 40910000000000000 wei
Cycle 1: sub balance drained to 31820000000000000 wei
Cycle 2: sub balance drained to 22730000000000000 wei
Cycle 3: sub balance drained to 13640000000000000 wei
Cycle 4: sub balance drained to 4550000000000000 wei
Cycle 5: sub balance 5460000000000000 wei < charge -> acquisition STRANDED.
Self-funding sustained only 5 fulfillments before the sub ran dry.
Add the test file to a Foundry project's test/ directory and run:
forge test --match-path "test/ExploitM02VrfFailOpen.t.sol" -vv
Recommended solution
Stop pricing the fee on request-time tx.gasprice, and stop relying on a fail-closed revert the coordinator does not provide:
Price against real fulfillment cost, not the requester's gas price. Read the coordinator's current gas-price/premium configuration at request time and apply a safety multiple, and on L2 add the L1 data-fee component, so the collected fee is a realistic over-estimate of what the sub will be charged at fulfillment. Alternatively, price on a configurable floor gas price so a cheap request block cannot under-collect.
Enforce fail-closed at the application layer. Since
requestRandomWordswill not revert on a dry sub, gateacquire/acquireBatchonsubscriptionNativeBalance() >= floor(a configurable buffer that comfortably exceeds one fulfillment charge) before issuing the request, so a request can never be fired against a subscription that cannot fund its callback. This makes the "fail-closed" behavior real instead of assumed.Correct the comment and the tests. Fix the
src/FWA.sol:1513-1515comment: the realVRFCoordinatorV2_5.requestRandomWordsdoes not revert on low balance. UpdateMockVRFCoordinatorand the suite so the default coordinator behavior matches mainnet (request succeeds regardless of balance), and add coverage for the unfulfilled-request path (anacquire()that succeeds against an under-funded sub and then cannot be fulfilled), rather than exercising aminBalanceForRequestrevert that has no on-chain counterpart.