diff --git a/lib/crc-pay.ts b/lib/crc-pay.ts index c46f90b..aa52ce2 100644 --- a/lib/crc-pay.ts +++ b/lib/crc-pay.ts @@ -36,6 +36,95 @@ export function splitAmounts(totalWei: bigint, expertPercent: ExpertSharePercent export type TransferTx = { to: string; data: string; value: string }; +const UNWRAP_SEL = '0xde0e9a3e'; // unwrap(uint256) on LiftERC20 +const REWRAP_SEL = '0xaabd6954'; // wrap(address,uint256,uint8) on Hub V2 + +type RawTx = { to: `0x${string}`; data: `0x${string}`; value: bigint }; + +/** + * When two payment legs share the same wrapped token route, TransferBuilder + * emits two identical unwrap calls for the same LiftERC20 contract. The second + * unwrap reverts because the balance was already consumed. This function merges + * the two batches so each LiftERC20 is unwrapped exactly once and the rewrap + * at the end accounts for both transfers: + * + * rewrap_merged = rewrap_leg1 + rewrap_leg2 - unwrap_amount + * = (X - A) + (X - B) - X = X - A - B ✓ + */ +function deduplicateWrappedOps(txs: RawTx[]): RawTx[] { + // Track unwrap amount per LiftERC20 contract (first occurrence wins) + const unwrapAmounts = new Map(); + for (const tx of txs) { + if (tx.data.toLowerCase().startsWith(UNWRAP_SEL)) { + const key = tx.to.toLowerCase(); + if (!unwrapAmounts.has(key)) { + unwrapAmounts.set(key, BigInt('0x' + tx.data.slice(10, 74))); + } + } + } + + // Group rewrap calls by (hub address + avatar address) + const rewrapGroups = new Map(); + for (const tx of txs) { + if (tx.data.toLowerCase().startsWith(REWRAP_SEL)) { + const avatar = tx.data.slice(34, 74).toLowerCase(); + const key = tx.to.toLowerCase() + avatar; + const amount = BigInt('0x' + tx.data.slice(74, 138)); + const group = rewrapGroups.get(key); + if (group) { + group.amounts.push(amount); + } else { + rewrapGroups.set(key, { tx, amounts: [amount] }); + } + } + } + + const seenUnwraps = new Set(); + const seenRewraps = new Set(); + const mainTxs: RawTx[] = []; + const mergedRewraps: RawTx[] = []; + + for (const tx of txs) { + const sel = tx.data.slice(0, 10).toLowerCase(); + + if (sel === UNWRAP_SEL) { + const key = tx.to.toLowerCase(); + if (!seenUnwraps.has(key)) { + seenUnwraps.add(key); + mainTxs.push(tx); + } + // duplicate unwrap — skip + } else if (sel === REWRAP_SEL) { + const avatar = tx.data.slice(34, 74).toLowerCase(); + const groupKey = tx.to.toLowerCase() + avatar; + if (seenRewraps.has(groupKey)) continue; + seenRewraps.add(groupKey); + + const group = rewrapGroups.get(groupKey)!; + if (group.amounts.length === 1) { + mergedRewraps.push(tx); + } else { + // merged = sum(rewraps) - (n-1) * unwrap_amount + // = (X-A) + (X-B) - X = X - A - B + const unwrapAmt = unwrapAmounts.size === 1 + ? [...unwrapAmounts.values()][0] + : 0n; + const totalRewrap = group.amounts.reduce((a, b) => a + b, 0n); + const corrected = totalRewrap - BigInt(group.amounts.length - 1) * unwrapAmt; + const safe = corrected > 0n ? corrected : 0n; + const amountHex = safe.toString(16).padStart(64, '0'); + // data layout: selector(10) + padded_addr(64) + amount(64) + type(64) + const newData = (tx.data.slice(0, 74) + amountHex + tx.data.slice(138)) as `0x${string}`; + mergedRewraps.push({ ...tx, data: newData }); + } + } else { + mainTxs.push(tx); + } + } + + return [...mainTxs, ...mergedRewraps]; +} + export async function buildDonationTransactions( from: `0x${string}`, amountCrc: number, @@ -50,35 +139,6 @@ export async function buildDonationTransactions( return txs.map((tx) => ({ to: tx.to, data: tx.data, value: tx.value.toString() })); } -// `unwrap(uint256)` selector on LiftERC20 wrapped-token contracts. -const UNWRAP_SELECTOR = '0xde0e9a3e'; - -/** - * Returns SimulatedBalance entries that zero-out every wrapped token the - * foundation leg plans to unwrap. Passing these into the expert-leg - * pathfinder forces it to choose a different route, preventing the - * double-unwrap that causes UserOperation simulation to revert. - * - * Root cause: constructAdvancedTransfer with useWrappedBalances unwraps the - * *entire* inflationary ERC20 balance per leg. Both legs run against the same - * on-chain snapshot, so each plans to unwrap the same tokens. When batched, - * the second unwrap fails because the balance is already spent. - */ -function depletedWrappedBalances( - from: `0x${string}`, - txs: Array<{ to: string; data: `0x${string}`; value: bigint }>, -) { - return txs - .filter((tx) => tx.data.startsWith(UNWRAP_SELECTOR)) - .map((tx) => ({ - holder: from, - token: tx.to as `0x${string}`, - amount: 0n, - isWrapped: true, - isStatic: true, - })); -} - export async function buildSplitPayTransactions( from: `0x${string}`, expert: `0x${string}`, @@ -94,25 +154,13 @@ export async function buildSplitPayTransactions( const totalWei = BigInt(totalCrc) * 10n ** 18n; const { expertWei, foundationWei } = splitAmounts(totalWei, expertPercent); - // Build the foundation leg first so we know which wrapped tokens it consumes. - const foundationTxs = await builder.constructAdvancedTransfer( - from, FOUNDATION_ADDRESS, foundationWei, { useWrappedBalances: true }, - ); - - // Build the expert leg sequentially, telling the pathfinder that any wrapped - // tokens already claimed by the foundation leg have zero remaining balance. - const expertTxs = expertWei > 0n - ? await builder.constructAdvancedTransfer( - from, expert, expertWei, { - useWrappedBalances: true, - simulatedBalances: depletedWrappedBalances(from, foundationTxs), - }, - ) - : []; - - return [...foundationTxs, ...expertTxs].map((tx) => ({ - to: tx.to, - data: tx.data, - value: tx.value.toString(), - })); + const [foundationTxs, expertTxs] = await Promise.all([ + builder.constructAdvancedTransfer(from, FOUNDATION_ADDRESS, foundationWei, { useWrappedBalances: true }), + expertWei > 0n + ? builder.constructAdvancedTransfer(from, expert, expertWei, { useWrappedBalances: true }) + : Promise.resolve([]), + ]); + + const merged = deduplicateWrappedOps([...foundationTxs, ...expertTxs]); + return merged.map((tx) => ({ to: tx.to, data: tx.data, value: tx.value.toString() })); }