rspace-online/modules/rwallet/lib/yield-tx-builder.ts

240 lines
7.2 KiB
TypeScript

/**
* Safe transaction builder for yield operations — builds calldata for
* approve+deposit (via MultiSend) and single-tx withdrawals.
*/
import type { SupportedYieldChain, YieldProtocol, StablecoinSymbol } from "./yield-protocols";
import {
AAVE_V3_POOL, MORPHO_VAULTS, MULTISEND_CALL_ONLY,
STABLECOIN_ADDRESSES, STABLECOIN_DECIMALS,
encodeApprove, encodeAaveSupply, encodeAaveWithdraw,
encodeMorphoDeposit, encodeMorphoRedeem,
encodeMaxDeposit, encodeBalanceOf,
SELECTORS, padUint256,
AAVE_ATOKENS,
} from "./yield-protocols";
import { getRpcUrl } from "../mod";
export interface SafeTxData {
to: string;
value: string;
data: string;
operation: number; // 0 = Call, 1 = DelegateCall
description: string;
steps: string[];
}
// ── MultiSend encoding ──
interface MultiSendTx {
operation: number; // 0 = Call
to: string;
value: bigint;
data: string; // hex with 0x prefix
}
function encodeMultiSend(txs: MultiSendTx[]): string {
let packed = "";
for (const tx of txs) {
const dataBytes = tx.data.startsWith("0x") ? tx.data.slice(2) : tx.data;
const dataLength = dataBytes.length / 2;
packed +=
padUint256(BigInt(tx.operation)).slice(62, 64) + // operation: 1 byte (last byte of uint8)
tx.to.slice(2).toLowerCase().padStart(40, "0") + // to: 20 bytes
padUint256(tx.value) + // value: 32 bytes
padUint256(BigInt(dataLength)) + // dataLength: 32 bytes
dataBytes; // data: variable
}
// Wrap in multiSend(bytes) — ABI encode: selector + offset(32) + length(32) + data + padding
const packedBytes = packed;
const packedLength = packedBytes.length / 2;
const offset = padUint256(32n); // bytes offset
const length = padUint256(BigInt(packedLength));
// Pad to 32-byte boundary
const padLen = (32 - (packedLength % 32)) % 32;
const padding = "0".repeat(padLen * 2);
return `${SELECTORS.multiSend}${offset}${length}${packedBytes}${padding}`;
}
// ── RPC helper ──
async function rpcCall(rpcUrl: string, to: string, data: string): Promise<string | null> {
try {
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: 1,
method: "eth_call",
params: [{ to, data }, "latest"],
}),
signal: AbortSignal.timeout(5000),
});
const result = (await res.json()).result;
if (!result || result === "0x") return null;
return result;
} catch {
return null;
}
}
// ── Deposit builder ──
export async function buildDepositTransaction(
chainId: SupportedYieldChain,
safeAddress: string,
protocol: YieldProtocol,
asset: StablecoinSymbol,
amount: bigint,
vaultAddress?: string, // required for Morpho (which vault)
): Promise<SafeTxData> {
const assetAddress = STABLECOIN_ADDRESSES[chainId]?.[asset];
if (!assetAddress || assetAddress === "0x0000000000000000000000000000000000000000") {
throw new Error(`${asset} not available on chain ${chainId}`);
}
const decimals = STABLECOIN_DECIMALS[asset];
const amountStr = `${Number(amount) / 10 ** decimals} ${asset}`;
if (protocol === "aave-v3") {
const pool = AAVE_V3_POOL[chainId];
const approveTx: MultiSendTx = {
operation: 0,
to: assetAddress,
value: 0n,
data: encodeApprove(pool, amount),
};
const supplyTx: MultiSendTx = {
operation: 0,
to: pool,
value: 0n,
data: encodeAaveSupply(assetAddress, amount, safeAddress),
};
return {
to: MULTISEND_CALL_ONLY[chainId],
value: "0",
data: encodeMultiSend([approveTx, supplyTx]),
operation: 1, // DelegateCall for MultiSend
description: `Deposit ${amountStr} to Aave V3`,
steps: [
`Approve Aave V3 Pool to spend ${amountStr}`,
`Supply ${amountStr} to Aave V3 Pool`,
],
};
}
// Morpho Blue vault
const vault = vaultAddress || MORPHO_VAULTS[chainId]?.find((v) => v.asset === asset)?.address;
if (!vault) throw new Error(`No Morpho vault for ${asset} on chain ${chainId}`);
// Check vault capacity
const rpcUrl = getRpcUrl(chainId);
if (rpcUrl) {
const maxHex = await rpcCall(rpcUrl, vault, encodeMaxDeposit(safeAddress));
if (maxHex) {
const maxDeposit = BigInt(maxHex);
if (maxDeposit < amount) {
throw new Error(`Morpho vault capacity exceeded: max ${Number(maxDeposit) / 10 ** decimals} ${asset}`);
}
}
}
const approveTx: MultiSendTx = {
operation: 0,
to: assetAddress,
value: 0n,
data: encodeApprove(vault, amount),
};
const depositTx: MultiSendTx = {
operation: 0,
to: vault,
value: 0n,
data: encodeMorphoDeposit(amount, safeAddress),
};
const vaultName = MORPHO_VAULTS[chainId]?.find((v) => v.address.toLowerCase() === vault.toLowerCase())?.name || "Morpho Vault";
return {
to: MULTISEND_CALL_ONLY[chainId],
value: "0",
data: encodeMultiSend([approveTx, depositTx]),
operation: 1, // DelegateCall for MultiSend
description: `Deposit ${amountStr} to ${vaultName}`,
steps: [
`Approve ${vaultName} to spend ${amountStr}`,
`Deposit ${amountStr} into ${vaultName}`,
],
};
}
// ── Withdraw builder ──
export async function buildWithdrawTransaction(
chainId: SupportedYieldChain,
safeAddress: string,
protocol: YieldProtocol,
asset: StablecoinSymbol,
amount: bigint | "max",
vaultAddress?: string,
): Promise<SafeTxData> {
const assetAddress = STABLECOIN_ADDRESSES[chainId]?.[asset];
if (!assetAddress || assetAddress === "0x0000000000000000000000000000000000000000") {
throw new Error(`${asset} not available on chain ${chainId}`);
}
const decimals = STABLECOIN_DECIMALS[asset];
if (protocol === "aave-v3") {
// For "max", use type(uint256).max which tells Aave to withdraw everything
const withdrawAmount = amount === "max"
? (2n ** 256n - 1n)
: amount;
const amountStr = amount === "max" ? `all ${asset}` : `${Number(amount) / 10 ** decimals} ${asset}`;
return {
to: AAVE_V3_POOL[chainId],
value: "0",
data: encodeAaveWithdraw(assetAddress, withdrawAmount, safeAddress),
operation: 0, // Direct call
description: `Withdraw ${amountStr} from Aave V3`,
steps: [`Withdraw ${amountStr} from Aave V3 Pool`],
};
}
// Morpho vault
const vault = vaultAddress || MORPHO_VAULTS[chainId]?.find((v) => v.asset === asset)?.address;
if (!vault) throw new Error(`No Morpho vault for ${asset} on chain ${chainId}`);
const vaultName = MORPHO_VAULTS[chainId]?.find((v) => v.address.toLowerCase() === vault.toLowerCase())?.name || "Morpho Vault";
// For "max", query current share balance
let shares: bigint;
if (amount === "max") {
const rpcUrl = getRpcUrl(chainId);
if (!rpcUrl) throw new Error("No RPC URL for chain");
const balHex = await rpcCall(rpcUrl, vault, encodeBalanceOf(safeAddress));
if (!balHex) throw new Error("Could not query vault balance");
shares = BigInt(balHex);
if (shares === 0n) throw new Error("No shares to withdraw");
} else {
// For specific amounts, we'd need previewWithdraw — approximate with shares = amount (ERC-4626 1:1-ish for stables)
// This is approximate; the actual share amount may differ slightly
shares = amount;
}
const amountStr = amount === "max" ? `all ${asset}` : `${Number(amount) / 10 ** decimals} ${asset}`;
return {
to: vault,
value: "0",
data: encodeMorphoRedeem(shares, safeAddress, safeAddress),
operation: 0,
description: `Withdraw ${amountStr} from ${vaultName}`,
steps: [`Redeem shares from ${vaultName} for ${amountStr}`],
};
}