Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions tests/utils/payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
GAS_LIMIT_CAP,
approvalAmountFor,
boundedGasLimit,
merkleMaxCharge,
payForQuotes,
type RawPayment,
} from '~/utils/payment'
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 26 additions & 6 deletions utils/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down