Copy import { ethers, network } from 'hardhat';
import { BigNumber } from 'ethers';
import { loadFixture } from '@nomicfoundation/hardhat-network-helpers';
import EthCrypto from 'eth-crypto';
import { expect } from 'chai';
import {
AccessToken,
CreditToken,
Escrow,
Factory,
Helper,
MockTransferValidatorV2,
RoyaltiesReceiverV2,
SignatureVerifier,
Staking,
BelongCheckIn,
VestingWalletExtended,
} from '../../../typechain-types';
import { CustomerInfoStruct, VenueInfoStruct, VenueRulesStruct } from '../../../typechain-types/contracts/v2/platform/BelongCheckIn';
import { deployHelper, deploySignatureVerifier } from '../../../helpers/deployLibraries';
import {
deployBelongCheckIn,
deployCreditTokens,
deployEscrow,
deployFactory,
deployRoyaltiesReceiverV2Implementation,
deployAccessTokenImplementation,
deployCreditTokenImplementation,
deployStaking,
deployVestingWalletImplementation,
} from '../../../helpers/deployFixtures';
import { deployMockTransferValidatorV2, deployPriceFeeds } from '../../../helpers/deployMockFixtures';
import { getSignerFromAddress, getToken } from '../../../helpers/fork';
import { ChainIds, chainRPCs } from '../../../utils/chain-ids';
import { U, u } from '../../../helpers/math';
// This PoC demonstrates that processingFeePercentage is applied to the full LONG amount
// instead of to the subsidy amount during LONG payments, underpaying the venue.
describe('PoC: processingFee applied to full LONG instead of subsidy', () => {
const chainId = 31337; // hardhat local
// Mainnet artifacts (same as existing tests)
const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const ENA_ADDRESS = '0x57e114B691Db790C35207b2e685D4A43181e6061'; // used instead of LONG
const USDC_WHALE_ADDRESS = '0x8EB8a3b98659Cce290402893d0123abb75E3ab28';
const ENA_WHALE_ADDRESS = '0xF977814e90dA44bFA03b6295A0616a897441aceC';
const UNISWAP_FACTORY_ADDRESS = '0x1F98431c8aD98523631AE4a59f267346ea31F984';
const UNISWAP_ROUTER_ADDRESS = '0xE592427A0AEce92De3Edee1F18E0157C05861564';
const UNISWAP_QUOTER_ADDRESS = '0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6';
const POOL_FEE = 3000;
const MAX_PRICEFEED_DELAY = 3600;
before(async () => {
await network.provider.request({
method: 'hardhat_reset',
params: [
{
forking: {
jsonRpcUrl: chainRPCs(ChainIds.mainnet),
enable: true,
},
},
],
});
});
async function fixture() {
const [admin, treasury, manager, minter, burner, pauser, referral] = await ethers.getSigners();
const signer = EthCrypto.createIdentity();
const USDC_whale = await getSignerFromAddress(USDC_WHALE_ADDRESS);
const ENA_whale = await getSignerFromAddress(ENA_WHALE_ADDRESS);
const USDC = await getToken(USDC_ADDRESS);
const ENA = await getToken(ENA_ADDRESS);
const signatureVerifier: SignatureVerifier = await deploySignatureVerifier();
const validator: MockTransferValidatorV2 = await deployMockTransferValidatorV2();
const accessTokenImplementation: AccessToken = await deployAccessTokenImplementation(signatureVerifier.address);
const royaltiesReceiverV2Implementation: RoyaltiesReceiverV2 = await deployRoyaltiesReceiverV2Implementation();
const creditTokenImplementation: CreditToken = await deployCreditTokenImplementation();
const vestingWallet: VestingWalletExtended = await deployVestingWalletImplementation();
const implementations: Factory.ImplementationsStruct = {
accessToken: accessTokenImplementation.address,
creditToken: creditTokenImplementation.address,
royaltiesReceiver: royaltiesReceiverV2Implementation.address,
vestingWallet: vestingWallet.address,
};
const factory: Factory = await deployFactory(
treasury.address,
signer.address,
signatureVerifier.address,
validator.address,
implementations,
);
const helper: Helper = await deployHelper();
const staking: Staking = await deployStaking(admin.address, treasury.address, ENA_ADDRESS);
const belongCheckIn: BelongCheckIn = await deployBelongCheckIn(
signatureVerifier.address,
helper.address,
admin.address,
{
swapPoolFees: POOL_FEE,
slippageBps: BigNumber.from(10).pow(27).sub(1),
swapV3Factory: UNISWAP_FACTORY_ADDRESS,
swapV3Router: UNISWAP_ROUTER_ADDRESS,
swapV3Quoter: UNISWAP_QUOTER_ADDRESS,
wNativeCurrency: WETH_ADDRESS,
usdc: USDC_ADDRESS,
long: ENA_ADDRESS,
maxPriceFeedDelay: MAX_PRICEFEED_DELAY,
},
);
const escrow: Escrow = await deployEscrow(belongCheckIn.address);
const { pf1 } = await deployPriceFeeds();
// Wire contracts
const { venueToken, promoterToken } = await deployCreditTokens(
true,
false,
factory.address,
signer.privateKey,
admin,
admin.address,
belongCheckIn.address,
belongCheckIn.address,
);
await belongCheckIn.setContracts({
factory: factory.address,
escrow: escrow.address,
staking: staking.address,
venueToken: venueToken.address,
promoterToken: promoterToken.address,
longPF: pf1.address,
});
return {
admin,
treasury,
referral,
signer,
belongCheckIn,
escrow,
helper,
USDC,
ENA,
USDC_whale,
ENA_whale,
};
}
it('demonstrates underpayment due to fee-on-full instead of fee-on-subsidy', async () => {
const { belongCheckIn, escrow, helper, USDC, ENA, USDC_whale, ENA_whale, signer } = await loadFixture(fixture);
// Prepare a venue deposit (LONG-only payments)
const uri = 'uri://venue';
const venueAmount = await u(100, USDC); // $100
const venue = USDC_whale.address;
const venueMessage = ethers.utils.solidityKeccak256(
['address', 'bytes32', 'string', 'uint256'],
[venue, ethers.constants.HashZero, uri, chainId],
);
const venueSignature = EthCrypto.sign(signer.privateKey, venueMessage);
const venueInfo: VenueInfoStruct = {
rules: { paymentType: 2, bountyType: 0, longPaymentType: 0 } as VenueRulesStruct, // LONG only
venue,
amount: venueAmount,
referralCode: ethers.constants.HashZero,
uri,
signature: venueSignature,
};
// Approve and deposit (includes $5 convenience fee swapped to LONG into escrow)
const convenienceFeeAmount = U(5, 6);
await USDC.connect(USDC_whale).approve(belongCheckIn.address, convenienceFeeAmount.add(venueAmount));
await belongCheckIn.connect(USDC_whale).venueDeposit(venueInfo);
// Customer LONG payment with no promoter
const customerAmount = ethers.utils.parseEther('5'); // 5 LONG to keep escrow solvent against subsidy pulls
const customerMessage = ethers.utils.solidityKeccak256(
['bool', 'uint128', 'uint24', 'address', 'address', 'address', 'uint256', 'uint256'],
[
false, // paymentInUSDC
0, // visitBountyAmount
0, // spendBountyPercentage
ENA_whale.address, // customer
USDC_whale.address, // venueToPayFor
ethers.constants.AddressZero, // promoter
customerAmount, // amount
chainId,
],
);
const customerSignature = EthCrypto.sign(signer.privateKey, customerMessage);
const customerInfo: CustomerInfoStruct = {
paymentInUSDC: false,
visitBountyAmount: 0,
spendBountyPercentage: 0,
customer: ENA_whale.address,
venueToPayFor: USDC_whale.address,
promoter: ethers.constants.AddressZero,
amount: customerAmount,
signature: customerSignature,
};
await ENA.connect(ENA_whale).approve(belongCheckIn.address, customerAmount);
// Snapshot balances
const venueBalance_before = await ENA.balanceOf(USDC_whale.address);
// Execute payment
await belongCheckIn.connect(ENA_whale).payToVenue(customerInfo);
const venueBalance_after = await ENA.balanceOf(USDC_whale.address);
const actualVenueReceived = venueBalance_after.sub(venueBalance_before);
// Fetch fee parameters
const fees = (await belongCheckIn.belongCheckInStorage()).fees;
// Components
const grossSubsidy = await helper.calculateRate(fees.platformSubsidyPercentage, customerAmount);
const discount = await helper.calculateRate(fees.longCustomerDiscountPercentage, customerAmount);
const fromCustomer = BigNumber.from(customerAmount).sub(discount);
// Buggy behavior (processing fee applied to full amount)
const processingOnFull = await helper.calculateRate(fees.processingFeePercentage, customerAmount);
const buggyFromEscrow = BigNumber.from(grossSubsidy).sub(processingOnFull);
const buggyExpectedVenue = buggyFromEscrow.add(fromCustomer);
// Correct behavior (processing fee applied to subsidy)
const processingOnSubsidy = await helper.calculateRate(fees.processingFeePercentage, grossSubsidy);
const correctFromEscrow = BigNumber.from(grossSubsidy).sub(processingOnSubsidy);
const correctExpectedVenue = correctFromEscrow.add(fromCustomer);
// Pretty logging for visibility
const longDecimals = await ENA.decimals();
const fmt = (bn: BigNumber) => ethers.utils.formatUnits(bn, longDecimals);
const bpsToNum = (v: any) => (v && typeof v === 'object' && 'toNumber' in v ? v.toNumber() : Number(v));
const bpsFmt = (bps: any) => `${String(bps)} bps (${(bpsToNum(bps) / 100).toFixed(2)}%)`;
const shortfall = BigNumber.from(processingOnFull).sub(processingOnSubsidy);
const shortfallBpsOfFull = shortfall.mul(10000).div(customerAmount);
// Scenario breakdown
// Raw = wei, Pretty = token units (18 decimals)
// Note: ENA is used as LONG stand-in, assumed 18 decimals
// eslint-disable-next-line no-console
console.log('--- LONG payment PoC breakdown ---');
// eslint-disable-next-line no-console
console.log('customerAmount (raw, pretty):', customerAmount.toString(), fmt(customerAmount));
// eslint-disable-next-line no-console
console.log('fees:', {
platformSubsidyPercentage: bpsFmt(fees.platformSubsidyPercentage),
processingFeePercentage: bpsFmt(fees.processingFeePercentage),
longCustomerDiscountPercentage: bpsFmt(fees.longCustomerDiscountPercentage),
});
// eslint-disable-next-line no-console
console.log('grossSubsidy (raw, pretty):', grossSubsidy.toString(), fmt(grossSubsidy));
// eslint-disable-next-line no-console
console.log('discount (raw, pretty):', discount.toString(), fmt(discount));
// eslint-disable-next-line no-console
console.log('fromCustomer (raw, pretty):', fromCustomer.toString(), fmt(fromCustomer));
// eslint-disable-next-line no-console
console.log('processingFull (raw, pretty):', processingOnFull.toString(), fmt(processingOnFull));
// eslint-disable-next-line no-console
console.log('processingSubs (raw, pretty):', processingOnSubsidy.toString(), fmt(processingOnSubsidy));
// eslint-disable-next-line no-console
console.log('buggyFromEscrow (raw, pretty):', buggyFromEscrow.toString(), fmt(buggyFromEscrow));
// eslint-disable-next-line no-console
console.log('correctFromEsc. (raw, pretty):', correctFromEscrow.toString(), fmt(correctFromEscrow));
// eslint-disable-next-line no-console
console.log('buggyVenueTotal (raw, pretty):', buggyExpectedVenue.toString(), fmt(buggyExpectedVenue));
// eslint-disable-next-line no-console
console.log('correctVenueTot (raw, pretty):', correctExpectedVenue.toString(), fmt(correctExpectedVenue));
// eslint-disable-next-line no-console
console.log('actualVenueRecv (raw, pretty):', actualVenueReceived.toString(), fmt(actualVenueReceived));
// eslint-disable-next-line no-console
console.log('shortfall (raw, pretty):', shortfall.toString(), fmt(shortfall));
// eslint-disable-next-line no-console
console.log('shortfall vs full amount:', `${shortfallBpsOfFull.toString()} bps`, `(${(shortfallBpsOfFull.toNumber() / 100).toFixed(3)}%)`);
// PoC assertions
// 1) Actual matches buggy formula
expect(actualVenueReceived).to.eq(buggyExpectedVenue);
// 2) Actual is strictly less than correct amount (venue underpaid)
expect(correctExpectedVenue).to.gt(actualVenueReceived);
// 3) The shortfall equals processingOnFull - processingOnSubsidy
const expectedShortfall = BigNumber.from(processingOnFull).sub(processingOnSubsidy);
expect(correctExpectedVenue.sub(actualVenueReceived)).to.eq(expectedShortfall);
});
});