Recently, I've been working on MealForward, a community meal voucher project built for the Monad Hackathon.
The idea is straightforward: donors deposit funds into an on-chain escrow contract, authorized issuers distribute meal vouchers, and recipients redeem those vouchers at participating restaurants using temporary QR codes. Once a restaurant confirms a meal has been delivered, the escrow contract releases the corresponding payment to the merchant.
While reviewing the smart contracts, we came across an interesting question:
What if a smart contract executes exactly as designed, but the money still ends up being paid for something that never happened?
That question led us beyond Solidity-level security and into a broader discussion about collusion attacks, on-chain/off-chain consistency, EIP-712 authorization, delayed settlement, and trust assumptions.
Here's how our reasoning evolved.
1. It Started With Two Solidity Functions
We began by reviewing two functions in MealForward's testnet escrow contract: report() and settle().
Here are simplified excerpts from the implementation:
// Operator reports meal delivery
function report(
bytes32 operationId,
bytes32 voucherId,
bytes32 lockId
) external onlyRole(OPERATOR_ROLE) nonReentrant {
Voucher storage v = vouchers[voucherId];
if (v.status != 2 || v.lockId != lockId)
revert InvalidVoucherState();
v.status = 3;
// Move funds from R (issued) to H (pending settlement)
}
// Settler releases payment to the merchant
function settle(
bytes32 operationId,
bytes32 voucherId
) external onlyRole(SETTLER_ROLE) nonReentrant {
// Validate voucher state and update accounting
(bool paid,) =
merchant.call{value: priceWei}("");
if (!paid) revert PaymentFailed();
}
These are simplified excerpts. Operation replay checks, pause conditions, and parts of the accounting logic are omitted for clarity. The full implementation is available in MealForwardTestnet.sol.
At first glance, the access control looks reasonable.
The OPERATOR_ROLE can lock vouchers and report meal delivery, while the SETTLER_ROLE can release funds for vouchers that have reached the appropriate state.
The contract also restricts payments to a predefined merchant address and prevents the same voucher from being successfully settled multiple times.
But there is a fundamental limitation:
An authorized Operator can submit a valid delivery report without proving that a meal was actually delivered.
The contract can verify the caller's permissions and whether a state transition is allowed.
It cannot directly observe what happens inside a restaurant.
That led us to our first attack scenario.
2. What If the Operator and Merchant Collude?
Suppose an attacker compromises the Operator's private key and cooperates with a dishonest restaurant owner.
The attacker cannot directly withdraw funds using only OPERATOR_ROLE, because settle() requires a separate role.
However, the attacker can potentially create fraudulent redemption records using legitimately issued, unused vouchers.
The attack could work like this:
- The attacker uses the compromised Operator key to call
lock()on a valid, unredeemed voucher. - The Operator calls
report(), falsely declaring that a meal has been delivered. - The colluding merchant, assuming they have settlement authority, executes
settle(). - The escrow contract transfers the corresponding voucher value to the merchant's predefined wallet.
No meal was actually delivered.
Yet every transaction could satisfy the contract's access-control and state-transition requirements.
To be precise, the attacker cannot create unlimited funds. The attack is constrained by existing vouchers, escrow balances, and the roles available to the colluding parties.
But it can still cause existing donor funds to be released for fraudulent claims.
This is not necessarily a traditional Solidity vulnerability such as reentrancy or an arithmetic error.
It's a business-logic and trust-model vulnerability: authorized parties can collude to produce a financially incorrect outcome.
And that raises a bigger question:
How do we protect a DApp when the contracts are behaving correctly, but the participants feeding information into those contracts may be dishonest?
3. First Approach: On-Chain and Off-Chain Reconciliation
Before addressing collusion directly, we considered another practical problem.
Imagine a restaurant has already served a meal and the backend database records the voucher as delivered.
However, the on-chain lock() transaction fails because of a network issue or because the contract has been paused.
Now the two systems disagree:
| Off-chain database | On-chain contract |
|---|---|
| Voucher marked as delivered | Voucher remains issued |
| Meal has already been served | Lock was not completed |
| Voucher must not be reused | Contract state may still allow locking |
This is a classic on-chain/off-chain consistency problem.
Our first idea was to introduce Reconciliation.
An Indexer can observe on-chain transactions, event logs, and contract state, then compare them with off-chain business records such as vouchers, delivery confirmations, and operation histories.
When an inconsistency is detected, the system can flag it for investigation and recovery.
However, recovery is not as simple as repeatedly submitting transactions.
If a transaction is in an UNKNOWN state, we should investigate the original transaction hash, nonce, receipt, and signing records before creating another attempt.
If the transaction has definitively reverted, we need to understand why it failed before deciding whether a new attempt is safe.
A physical meal cannot be reversed simply because an on-chain transaction failed.
This means we need to distinguish between business state and blockchain transaction state rather than treating them as identical.
But reconciliation revealed another limitation.
What if the Operator and Merchant fabricate matching on-chain and off-chain records?
The database says the meal was delivered.
The blockchain says the voucher was reported.
Everything matches.
But no meal was served.
We realized that:
Data consistency does not imply factual correctness.
Reconciliation can detect discrepancies between systems, but it cannot independently prove that the underlying real-world event occurred.
4. Second Approach: Delayed Settlement
If monitoring can detect some suspicious activities, perhaps we can give the system more time to react before releasing funds.
This led us to Delayed Settlement, also known as a settlement challenge period.
Instead of immediately transferring funds after report(), the contract could enforce a waiting period.
For example:
Lock
↓
Report
↓
30-minute Settlement Delay
↓
Settle
↓
Merchant receives payment
During this period, the funds remain in escrow.
An independent monitoring service could compare business records with on-chain activity and look for unusual redemption patterns.
If suspicious activity is detected, an authorized Guardian could mark a voucher as disputed or pause future settlements.
Two conditions are important here.
First, the waiting period must be enforced by the smart contract itself.
If it exists only in backend code, a compromised privileged actor may be able to bypass it by interacting directly with the contract.
Second, monitoring and emergency intervention should not depend on the same compromised Operator key.
Otherwise, an attacker who controls the Operator might also be able to disable monitoring or interfere with incident response.
A possible settlement check would be:
require(
block.timestamp >=
reportedAt[voucherId] + SETTLEMENT_DELAY,
"Settlement not ready"
);
require(
!disputed[voucherId],
"Voucher disputed"
);
Of course, this would require additional contract state and carefully designed dispute permissions.
But even with a 30-minute delay, a dishonest Merchant and Operator could fabricate consistent records that the Monitor considers legitimate.
The Monitor might never detect anything suspicious.
So we reached another conclusion:
Delayed settlement provides time for detection and intervention. It does not prove physical delivery or eliminate collusion.
We needed a mechanism that prevented a compromised Operator from manufacturing authorized voucher claims in the first place.
5. Third Approach: Bring the Recipient's Voucher Into the Authorization Process
Until this point, the system relied heavily on the Operator's authority.
What if the Operator's private key alone were no longer sufficient to redeem a voucher?
Could we require proof that a valid recipient credential actually participated in the redemption?
This led us to the idea of a Claim Permit.
The central idea is simple:
An Operator should not be able to lock a voucher merely because it holds OPERATOR_ROLE.
It must also provide a valid, one-time authorization issued for a specific voucher and merchant.
For example:
struct ClaimPermit {
bytes32 voucherId;
address merchant;
bytes32 claimId;
uint256 nonce;
uint256 deadline;
}
The redemption flow could work as follows:
- The recipient presents a temporary QR code at the restaurant.
- A Redemption Authority validates the QR code, merchant identity, and voucher status.
- The backend atomically consumes or reserves the one-time presentation credential.
- The Redemption Authority signs a Claim Permit for that specific voucher and merchant.
- The Operator submits the signed Permit to the escrow contract through
lock(). - The contract verifies the signature, merchant binding, nonce, expiration time, and voucher state.
We can implement this authorization using EIP-712.
What Is EIP-712?
EIP-712 is an Ethereum standard for hashing and signing structured typed data.
Instead of signing an opaque message, we can define exactly which fields are being authorized.
For example, the Claim Permit could be signed under a domain containing:
{
"name": "MealForwardClaim",
"version": "1",
"chainId": 10143,
"verifyingContract": "ESCROW_CONTRACT_ADDRESS"
}
The domain helps bind the signature to the intended chain and contract.
We must still implement replay protection ourselves, such as nonce consumption, voucher-state checks, and expiration validation.
EIP-712 does not automatically provide those protections.
It's also important not to confuse our Claim Permit with EIP-2612's permit() mechanism for ERC-20 token allowances.
Our Claim Permit is a custom, application-level authorization built using EIP-712 signatures.
Why Does This Help Against a Compromised Operator?
Let's revisit our original attack scenario.
An attacker compromises the Operator's private key and cooperates with a dishonest merchant.
Previously, the Operator could attempt to lock any issued voucher that satisfied the on-chain state requirements.
With the proposed Claim Permit mechanism, the contract would reject lock() unless a valid authorization from the designated Redemption Authority accompanies the transaction.
The Operator cannot simply invent that signature.
Assuming the Redemption Authority remains honest, its signing credentials are secure, and the attacker cannot obtain a valid recipient credential, the attacker cannot fabricate an authorized redemption using only the compromised Operator key.
This changes the security model.
We are no longer trusting the Operator alone to decide whether a particular voucher should be redeemed.
Instead, the contract enforces an additional authorization boundary.
But Does a Claim Permit Prove a Meal Was Delivered?
No.
This is perhaps the most important lesson from the entire discussion.
Imagine a dishonest restaurant scans a legitimate recipient's QR code and obtains a valid Claim Permit.
The Operator then successfully locks the voucher.
But the restaurant never serves the meal.
Every cryptographic check may pass.
The Permit proves that the redemption was authorized under the defined rules.
It does not prove that the merchant fulfilled its real-world obligation.
We therefore need to distinguish among three concepts:
Claim Permit: Authorization to redeem a specific voucher.
Delivery Declaration: A statement from the merchant that a meal has been delivered.
Delivery Evidence: Information supporting the claim that delivery actually occurred, potentially including independent recipient confirmation, audits, or dispute records.
We could introduce an additional confirmation step after delivery, allowing recipients to confirm that they received their meals.
However, that comes with practical challenges.
Some recipients may not have smartphones, wallets, or reliable internet access.
And even a valid recipient confirmation cannot eliminate the possibility that the recipient and restaurant are colluding.
For MealForward, maintaining accessibility is a major product requirement, so requiring every recipient to have an Ethereum wallet would introduce significant friction.
The objective is therefore not to make an impossible claim of cryptographically proving every physical meal.
It is to reduce the authority of compromised operators, strengthen redemption authorization, introduce independent evidence, and limit the financial impact of fraudulent activity.
Conclusion: From Contract Correctness to System Security
We started by reviewing two Solidity functions: report() and settle().
At first, the question seemed to be whether the access-control logic was secure.
But by constructing a collusion attack, we discovered that smart contract correctness is only one part of DApp security.
Our reasoning evolved through three approaches:
- Reconciliation: Detect inconsistencies between on-chain transactions and off-chain business records.
- Delayed Settlement: Provide an enforceable window for monitoring, disputes, and emergency intervention.
- EIP-712 Claim Permit: Prevent a compromised Operator from creating authorized voucher claims using only its own private key.
Each approach addresses a different problem, and none is sufficient on its own.
For this first phase, we're working under one explicit assumption:
MealForward's core authorization infrastructure is trustworthy, but the Operator's private key may be compromised and the attacker may collude with a merchant.
Under that assumption, a properly enforced Claim Permit can meaningfully reduce the Operator's ability to create fraudulent redemptions.
These mechanisms are currently part of our security-design exploration, not a claim that the existing MealForward testnet contract has already implemented or passed an audit for them.
But our discussion eventually led to an even more difficult question:
What if MealForward itself becomes malicious?
What if the Operator, Redemption Authority, and Monitor are all controlled by the same platform administrator?
Would separate servers and separate private keys still provide meaningful security?
That's where our next discussion begins: Trust Domain Separation, independent authorization, and designing a DApp that doesn't require users to blindly trust the platform itself.
To be continued in Part 2.
Project: MealForward on GitHub
References:
- EIP-712: Typed Structured Data Hashing and Signing
- EIP-2612: Permit Extension for EIP-20 Signed Approvals
- OpenZeppelin AccessControl
I'd love to hear your thoughts: For DApps that depend on real-world fulfillment, what other practical approaches can reduce Operator–Merchant collusion without requiring every recipient to own a crypto wallet?
Top comments (0)