240 lines
7.2 KiB
TypeScript
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}`],
|
|
};
|
|
}
|