diff --git a/.changeset/wet-tools-suffer.md b/.changeset/wet-tools-suffer.md new file mode 100644 index 000000000..3d12dad19 --- /dev/null +++ b/.changeset/wet-tools-suffer.md @@ -0,0 +1,5 @@ +--- +"@delvtech/hyperdrive-js": patch +--- + +Add zap logic to ReadHyperdrive diff --git a/apps/sdk-sandbox/client.ts b/apps/sdk-sandbox/client.ts index 482bc77d1..0e97099d4 100644 --- a/apps/sdk-sandbox/client.ts +++ b/apps/sdk-sandbox/client.ts @@ -8,6 +8,20 @@ import { privateKeyToAccount } from "viem/accounts"; export const publicClient: PublicClient = createPublicClient({ transport: http(process.env.RPC_URL), + chain: { + id: 707, + name: "Zaps Cloudchain", + nativeCurrency: { + name: "ETH", + symbol: "ETH", + decimals: 18, + }, + rpcUrls: { + default: { + http: [process.env.RPC_URL!], + }, + }, + }, }); export const walletClient = process.env.WALLET_PRIVATE_KEY @@ -15,6 +29,7 @@ export const walletClient = process.env.WALLET_PRIVATE_KEY account: privateKeyToAccount( process.env.WALLET_PRIVATE_KEY as `0x${string}`, ), + chain: publicClient.chain, transport: http(process.env.RPC_URL), }) : undefined; diff --git a/apps/sdk-sandbox/package.json b/apps/sdk-sandbox/package.json index ed83ac9ce..dbb288491 100644 --- a/apps/sdk-sandbox/package.json +++ b/apps/sdk-sandbox/package.json @@ -17,7 +17,7 @@ "@delvtech/hyperdrive-appconfig": "*", "@delvtech/hyperdrive-js": "*", "@delvtech/hyperdrive-wasm": "*", - "viem": "^2.9.2" + "viem": "^2.22.19" }, "devDependencies": { "@types/node": "^20.14.2", diff --git a/apps/sdk-sandbox/scripts/example.ts b/apps/sdk-sandbox/scripts/example.ts index 07e3b9597..046a0d62b 100644 --- a/apps/sdk-sandbox/scripts/example.ts +++ b/apps/sdk-sandbox/scripts/example.ts @@ -1,21 +1,283 @@ import { Drift } from "@delvtech/drift"; import { viemAdapter } from "@delvtech/drift-viem"; -import { ReadHyperdrive } from "@delvtech/hyperdrive-js"; -import { publicClient } from "../client"; +import { fixed } from "@delvtech/fixed-point-wasm"; +import { appConfig } from "@delvtech/hyperdrive-appconfig"; +import { + ReadHyperdrive, + ReadWriteHyperdrive, + zapAbi, +} from "@delvtech/hyperdrive-js"; +import { Address, encodePacked, maxInt256 } from "viem"; +import { publicClient, walletClient } from "../client"; -const drift = new Drift(viemAdapter({ publicClient })); +const zapsConfig = appConfig.zaps[707]; +const drift = new Drift(viemAdapter({ publicClient, walletClient })); -const pool = new ReadHyperdrive({ - address: "0x324395D5d835F84a02A75Aa26814f6fD22F25698", +const poolAddress = "0x324395D5d835F84a02A75Aa26814f6fD22F25698"; +const earliestBlock = 20180617n; + +// Write instance for transactions +const writePool = new ReadWriteHyperdrive({ + address: poolAddress, + drift, + earliestBlock, +}); + +// Read instance (includes zapAddress) +const readPool = new ReadHyperdrive({ + address: poolAddress, drift, + auxiliaryContractAddress: zapsConfig.address, + earliestBlock, }); -const kind = await pool.getKind(); -const config = await pool.getPoolConfig(); +const poolContract = drift.contract({ + abi: writePool.contract.abi, + address: poolAddress, +}); + +// SAMPLE ASSET ID AND MATURITY +const assetId: bigint = + 452312848583266388373324160190187140051835877600158453279131187532665273856n; +const maturity = 1754611200n; + +async function openLongPosition() { + try { + const account = walletClient?.account.address as Address; + + const beforeDetails = await readPool.getOpenLongDetails({ + account, + assetId, + options: { block: "latest" }, + }); + console.log("openLongDetails before:", beforeDetails); + + const { result, request } = await publicClient.simulateContract({ + abi: writePool.contract.abi, + address: poolAddress, + chain: publicClient.chain, + functionName: "openLong", + account, + gas: 16125042n, + args: [ + BigInt(30e18), // 30 base tokens (DAI) + 1n, + 1n, + { + asBase: true, + destination: account, + extraData: "0x", + }, + ], + }); + + const openTxHash = await walletClient?.writeContract({ + ...request, + }); + if (!openTxHash) throw new Error("No open transaction hash received"); + + const txReceipt = await publicClient.waitForTransactionReceipt({ + hash: openTxHash, + }); + await drift.cache.clear(); + + if (!txReceipt) throw new Error("No open transaction receipt received"); + if (txReceipt.status !== "success") { + console.error("Open Transaction failed:", txReceipt); + throw new Error("Open Transaction failed"); + } + console.log("Open tx receipt status:", txReceipt.status); + + const afterDetails = await readPool.getOpenLongDetails({ + account, + assetId, + }); + console.log("openLongDetails after:", afterDetails); + + // Approve zap (auxiliary) contract to manage the long position + const approvalReceipt = await writePool.contract.write("setApproval", { + amount: maxInt256, + tokenID: assetId, + operator: zapsConfig.address, + }); + console.log("Approval tx hash:", approvalReceipt); + + // Simulate closeLong to preview base amount out + const { result: previewBaseAmountOut } = + await publicClient.simulateContract({ + abi: writePool.contract.abi, + address: poolAddress, + chain: publicClient.chain, + functionName: "closeLong", + account, + args: [ + maturity, + BigInt(40e18), // 40 base tokens (DAI) + 1n, + { + asBase: true, + destination: account, + extraData: "0x", + }, + ], + }); + console.log("Preview base out:", fixed(previewBaseAmountOut).format()); + + // Execute the zap close operation + const swapTx = await walletClient + ?.writeContract({ + abi: zapAbi, + chain: publicClient.chain, + address: zapsConfig.address, + functionName: "closeLongZap", + gas: 16125042n, + args: [ + poolAddress, + maturity, + BigInt(40e18), + 1n, + { + destination: zapsConfig.address, + asBase: true, + extraData: "0x", + }, + { + amountIn: previewBaseAmountOut, + amountOutMinimum: 1n, + deadline: (await publicClient.getBlock()).timestamp + 60n, + path: encodePacked( + ["address", "uint24", "address"], + [ + "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI + 100, // 0.01% fee tier + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + ], + ), + recipient: account, + }, + false, + ], + }) + .catch((err) => { + console.error("closeLongZap failed:", err); + throw err; + }); + if (!swapTx) throw new Error("No close position transaction hash received"); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: swapTx, + }); + await drift.cache.clear(); + + const openLongDetailsAfterZap = await readPool.getOpenLongDetails({ + account, + assetId, + }); + console.log("openLongDetails after zap close:", openLongDetailsAfterZap); + + return txReceipt; + } catch (error) { + console.error("Failed to open long position:", error); + throw error; + } +} + +async function closeAllPositions() { + const account = walletClient?.account.address as Address; + const block = await publicClient.getBlock(); + + // Example: manually defined positions + const manualPositions = [ + { + assetId: + 452312848583266388373324160190187140051835877600158453279131187532665273856n, + maturity: 1754611200n, + bondAmount: 104902143926345435824n, // Example: 100 bonds + }, + // Add more positions as needed + ]; + + for (const position of manualPositions) { + try { + console.log("\nProcessing position:", position); + + // Approve zap contract to manage the position + const approvalReceipt = await poolContract.write("setApproval", { + amount: maxInt256, + tokenID: position.assetId, + operator: zapsConfig.address, + }); + console.log("Approval tx hash:", approvalReceipt); + + // Preview base out from closeLong + const { result: previewBaseAmountOut } = + await publicClient.simulateContract({ + abi: writePool.contract.abi, + address: poolAddress, + functionName: "closeLong", + account, + args: [ + position.maturity, + position.bondAmount, + 1n, + { + asBase: true, + destination: account, + extraData: "0x", + }, + ], + }); + console.log("Preview base out:", fixed(previewBaseAmountOut).format()); + + // Execute zap close for the position + const swapTx = await walletClient?.writeContract({ + abi: zapAbi, + chain: publicClient.chain, + address: zapsConfig.address, + functionName: "closeLongZap", + args: [ + poolAddress, + position.maturity, + position.bondAmount, + 1n, + { + destination: zapsConfig.address, + asBase: true, + extraData: "0x", + }, + { + amountIn: previewBaseAmountOut, + amountOutMinimum: 1n, + deadline: (await publicClient.getBlock()).timestamp + 60n, + path: encodePacked( + ["address", "uint24", "address"], + [ + "0x6B175474E89094C44Da98b954EedeAC495271d0F", + 100, + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + ], + ), + recipient: account, + }, + false, + ], + }); + const openLongDetails = await writePool.getOpenLongDetails({ + account, + assetId: position.assetId, + }); + console.log("openLongDetails:", openLongDetails); + console.log("Closed position tx hash:", swapTx); + } catch (error) { + console.error(`Failed to close position ${position.assetId}:`, error); + } + } +} + +async function main() { + // Uncomment the function call you need + // await openLongPosition(); + await closeAllPositions(); +} -console.log(` - address: ${pool.address} - kind: ${kind} - baseToken: ${config.baseToken} - sharesToken: ${config.vaultSharesToken} -`); +main().catch(console.error); diff --git a/packages/hyperdrive-js/src/hyperdrive/ReadHyperdrive.ts b/packages/hyperdrive-js/src/hyperdrive/ReadHyperdrive.ts index 4890df155..f3dd225eb 100644 --- a/packages/hyperdrive-js/src/hyperdrive/ReadHyperdrive.ts +++ b/packages/hyperdrive-js/src/hyperdrive/ReadHyperdrive.ts @@ -43,12 +43,19 @@ import { hyperwasm } from "src/hyperwasm"; import { ReadErc20 } from "src/token/erc20/ReadErc20"; import { ReadEth } from "src/token/eth/ReadEth"; -export interface ReadHyperdriveOptions extends ReadContractClientOptions {} +export interface ReadHyperdriveOptions extends ReadContractClientOptions { + zapContractAddress?: Address; +} export class ReadHyperdrive extends ReadClient { readonly address: Address; readonly contract: ReadContract; + /** + * The optional address of an auxiliary contract such as a zap contract. + */ + readonly zapContractAddress?: Address; + /** * @hidden */ @@ -58,10 +65,12 @@ export class ReadHyperdrive extends ReadClient { cache, cacheNamespace, drift, + zapContractAddress, ...rest }: ReadHyperdriveOptions) { super({ debugName, drift, ...rest }); this.address = address; + this.zapContractAddress = zapContractAddress; this.contract = this.drift.contract({ abi: hyperdriveAbi, address, @@ -773,7 +782,6 @@ export class ReadHyperdrive extends ReadClient { return Object.values(openLongs).filter((long) => long.bondAmount); } - // TODO: Rename this to getOpenLongs once this function replaces the existing getOpenLongs async getOpenLongPositions({ account, options, @@ -790,72 +798,41 @@ export class ReadHyperdrive extends ReadClient { toBlock: options?.block, }); - const longsReceived = transfersReceived.filter((event) => { - const { assetType } = decodeAssetFromTransferSingleEventData( - event.data as `0x${string}`, - ); - return assetType === "LONG"; - }); - - const longsSent = transfersSent.filter((event) => { - const { assetType } = decodeAssetFromTransferSingleEventData( - event.data as `0x${string}`, - ); - return assetType === "LONG"; - }); + const longsReceived = transfersReceived.filter(isLongEvent); + const longsSent = transfersSent.filter(isLongEvent); // Put open and long events in block order. We spread openLongEvents first // since you have to open a long before you can close one. - const orderedLongEvents = [...longsReceived, ...longsSent].sort( + const orderedEvents = [...longsReceived, ...longsSent].sort( (a, b) => Number(a.blockNumber) - Number(b.blockNumber), ); const openLongs: Record = {}; - orderedLongEvents.forEach((event) => { - const assetId = event.args.id.toString(); - - const long: OpenLongPositionReceivedWithoutDetails = openLongs[ - assetId - ] || { - assetId, + for (const event of orderedEvents) { + const position = openLongs[event.args.id.toString()] ?? { + assetId: event.args.id, maturity: decodeAssetFromTransferSingleEventData( event.data as `0x${string}`, ).timestamp, value: 0n, }; - const isLongReceived = event.args.to === account; - if (isLongReceived) { - const updatedLong: OpenLongPositionReceivedWithoutDetails = { - ...long, - value: long.value + event.args.value, - }; - openLongs[assetId] = updatedLong; - return; - } - - const isLongSent = event.args.from === account; - if (isLongSent) { - // If a user closes their whole position, we should remove the whole - // position since it's basically starting over - if (event.args.value === long.value) { - delete openLongs[assetId]; - return; + if (event.args.to === account) { + position.value += event.args.value; + } else if (event.args.from === account) { + position.value -= event.args.value; + if (position.value === 0n) { + delete openLongs[event.args.id.toString()]; + continue; } - // otherwise just subtract the amount of bonds they closed and baseAmount - // they received back from the running total - const updatedLong: OpenLongPositionReceivedWithoutDetails = { - ...long, - value: long.value - event.args.value, - }; - openLongs[assetId] = updatedLong; } - }); + openLongs[event.args.id.toString()] = position; + } + return Object.values(openLongs).filter((long) => long.value); } - async getOpenLongDetails({ assetId, account, @@ -864,47 +841,55 @@ export class ReadHyperdrive extends ReadClient { assetId: bigint; account: `0x${string}`; options?: ContractReadOptions; - }): Promise { - const allLongPositions = await this.getOpenLongPositions({ - account, - options, - }); - - const longPosition = allLongPositions.find((p) => p.assetId === assetId); - - if (!longPosition) { + }): Promise { + // Ensure the account has an open long position for this asset. + const allPositions = await this.getOpenLongPositions({ account, options }); + const position = allPositions.find((p) => p.assetId === assetId); + if (!position) { throw new HyperdriveSdkError( `No position with asset id: ${assetId} found for account ${account}`, ); } + // Fetch the standard OpenLong and CloseLong events. const openLongEvents = await this.contract.getEvents("OpenLong", { - filter: { trader: account }, + filter: { trader: account, assetId }, }); - const closeLongEvents = await this.contract.getEvents("CloseLong", { - filter: { trader: account }, + filter: { trader: account, assetId }, }); - const allOpenLongDetails = this._calcOpenLongs({ - openLongEvents, - closeLongEvents, + // Handle transfers sent to the contract. + const transfersSentToAux = await this.contract.getEvents("TransferSingle", { + filter: { from: account, to: this.zapContractAddress }, + toBlock: options?.block, }); - const openLongDetails = allOpenLongDetails.find( - (details) => - details.assetId.toString() === longPosition.assetId.toString(), - ); - // If no details exists for the position, the user must have just received - // some longs via transfer but never opened them themselves. - // OR If the amounts aren't the same, then they may have opened some and - // received some from another wallet. In this case, we still can't be sure - // of the details, so we return undefined. - if (!openLongDetails || openLongDetails.bondAmount !== longPosition.value) { - return; + if (transfersSentToAux.length) { + const accountTxHashes = transfersSentToAux.map( + ({ transactionHash }) => transactionHash, + ); + // Fetch CloseLong events emitted by the auxiliary contract in the relevant block range. + const allAuxCloses = await this.contract.getEvents("CloseLong", { + filter: { trader: this.zapContractAddress, assetId }, + fromBlock: transfersSentToAux[0].blockNumber, + toBlock: transfersSentToAux.at(-1)?.blockNumber, + }); + // Only include events that occurred in the same transactions. + const auxClosesForAccount = allAuxCloses.filter(({ transactionHash }) => + accountTxHashes.includes(transactionHash as `0x${string}`), + ); + for (const event of auxClosesForAccount) { + closeLongEvents.push(event); + } } - return openLongDetails; + // Calculate net open long using the helper. + const calculatedLongs = this._calcOpenLongs({ + openLongEvents, + closeLongEvents, + }); + return calculatedLongs[0]; } /** * @deprecated Use ReadHyperdrive.getOpenLongPositions and ReadHyperdrive.getOpenLongDetails instead to retrieve all longs opened or received by a user. @@ -2076,3 +2061,12 @@ function calculateApyFromPrice({ const yearFraction = fixed(timeFrame).div(SECONDS_PER_YEAR); return priceRatio.pow(fixed(1e18).div(yearFraction)).sub(1e18).bigint; } + +function isLongEvent( + event: ContractEvent, +): boolean { + const { assetType } = decodeAssetFromTransferSingleEventData( + event.data as `0x${string}`, + ); + return assetType === "LONG"; +}