Skip to content
Open
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
148 changes: 98 additions & 50 deletions lib/crc-pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, bigint>();
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<string, { tx: RawTx; amounts: bigint[] }>();
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<string>();
const seenRewraps = new Set<string>();
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,
Expand All @@ -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}`,
Expand All @@ -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() }));
}