diff --git a/tests/utils/payment.test.ts b/tests/utils/payment.test.ts index 18c2e97..7f68314 100644 --- a/tests/utils/payment.test.ts +++ b/tests/utils/payment.test.ts @@ -11,6 +11,7 @@ import { GAS_LIMIT_CAP, approvalAmountFor, boundedGasLimit, + merkleMaxCharge, payForQuotes, type RawPayment, } from '~/utils/payment' @@ -39,6 +40,36 @@ describe('payment', () => { }) }) + describe('merkleMaxCharge', () => { + // 16 candidates like the contract's fixed pool size, amounts 1..16. + const pool = (amounts: number[]) => ({ + candidates: amounts.map(a => ({ amount: BigInt(a) })), + }) + const AMOUNTS = Array.from({ length: 16 }, (_, i) => i + 1) + + it('mirrors the contract: median (index 8 of sorted 16) × 2^depth', () => { + // sorted[8] = 9; depth 7 → 9 × 128 = 1152 + expect(merkleMaxCharge(7, [pool(AMOUNTS)])).toBe(1152n) + }) + + it('exceeds the old max-pool-sum bound for deep trees (the 0xfb8f41b2 regression)', () => { + // Pool sum is 136 — the allowance the old code verified. The vault + // actually pulls 1152 at depth 7; anything between reverted on-chain. + const sum = AMOUNTS.reduce((a, b) => a + b, 0) + expect(merkleMaxCharge(7, [pool(AMOUNTS)])).toBeGreaterThan(BigInt(sum)) + }) + + it('takes the worst case across pools', () => { + const cheap = pool(AMOUNTS) + const dear = pool(AMOUNTS.map(a => a * 100)) + expect(merkleMaxCharge(5, [cheap, dear])).toBe(900n * 32n) + }) + + it('handles empty pools without throwing', () => { + expect(merkleMaxCharge(7, [{ candidates: [] }])).toBe(0n) + }) + }) + describe('boundedGasLimit', () => { it('adds 50% headroom to the estimate', () => { expect(boundedGasLimit(2_000_000n)).toBe(3_000_000n) diff --git a/utils/payment.ts b/utils/payment.ts index 8d5224c..9b9cb0a 100644 --- a/utils/payment.ts +++ b/utils/payment.ts @@ -234,6 +234,31 @@ export async function payForQuotes( * Pay for merkle tree via the PaymentVault contract (merkle path). * Single transaction for all chunks — lower gas for large uploads. */ +/** Upper bound of what PaymentVaultV2.payForMerkleTree will actually pull: + * the vault charges `median(winner pool's 16 candidate amounts) × 2^depth` + * (median16 = element 8 of the sorted 16), and picks the winner pool + * on-chain from sender+timestamp — unknowable here, so take the worst + * case across pools. The previous bound — the largest pool's SUM — is + * ≈16×median, which UNDERSHOOTS the real charge by 2^depth/16 for + * depth ≥ 5 (8× at depth 7). Wallets whose remaining allowance fell in + * that gap skipped the approve and then reverted every retry with + * ERC20InsufficientAllowance (0xfb8f41b2). */ +export function merkleMaxCharge( + depth: number, + pools: { candidates: { amount: bigint }[] }[], +): bigint { + return pools.reduce((max, pool) => { + const amounts = pool.candidates + .map(c => c.amount) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + if (amounts.length === 0) return max + // Contract's median16 selects index 8 of its fixed 16-slot array. + const median = amounts[Math.min(8, amounts.length - 1)]! + const charge = median * (1n << BigInt(depth)) + return charge > max ? charge : max + }, 0n) +} + export async function payForMerkleTree( wagmiConfig: any, depth: number, @@ -250,12 +275,7 @@ export async function payForMerkleTree( })), })) - const maxPoolCost = commitments.reduce((max, pc) => { - const poolTotal = pc.candidates.reduce((sum, c) => sum + c.amount, 0n) - return poolTotal > max ? poolTotal : max - }, 0n) - - let gasSpent = await ensureAllowance(wagmiConfig, maxPoolCost) + let gasSpent = await ensureAllowance(wagmiConfig, merkleMaxCharge(depth, commitments)) const call = { abi: paymentVaultAbi,