/** * 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 { 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 { 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 { 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}`], }; }