--- name: soulzap-vexy description: Adds SoulZap support to Vexy - buying a listed veNFT with any token the user already holds, by swapping into the token the listing is priced in and calling buyListing in the same transaction. Use this whenever the task involves a Vexy listing. Companion to soulzap-integration.txt, which must be read first. --- # Integrate SoulZap into Vexy > **Read `soulzap-integration.txt` first** - it carries the flow, the rules and every `lpData` > variant. This file only adds what is specific to Vexy. ## The goal One transaction that takes a single token the user already holds and leaves them holding a listed veNFT: swap into the token the listing is priced in, buy the listing, receive the NFT - atomic, reverting as a whole if any leg fails. Today a buyer must already hold the exact currency; this removes that. The existing buy flow stays exactly as it is. ## Start in the codebase Find these before writing anything, and reuse them: - **The existing buy path** - the `approve` on the currency followed by `buyListing(listingId)`. The zap replaces those two calls with `/approve` plus one `txData`, not the surrounding UX. - **Where listings come from** - the reads or indexer behind the listing grid. The zap needs the `listingId` and nothing else; do not add a second source for listing data. - **The wallet helpers** - send, wait-for-receipt, and the ERC-20 approve/allowance helper. - **The token selector.** Paying with any held token is the entire point of this integration. If the buy screen only offers the listing's currency, that list is what needs widening. Then make the smallest change that adds the path: build `lpData`, `POST /approve`, `POST /zap`, send `txData` through the helper that already exists. No new dependency - `fetch` is enough. ## What we know about Vexy - An ERC721 marketplace. The zap covers **Base** only - send `"chain": "base"` (or `8453`). The marketplace is deployed at the same address on Optimism, which the API does not serve. - The listing decides its own currency and its own price. Neither is a caller input. - A listing can be a **fixed price** or a **Dutch auction** (`slopeDuration > 0`), which decays towards the stored price and never rises above it. - Vexy takes a 1% fee out of the price, inside `buyListing`. It is not an extra amount to send. - `buyListing(uint256)` takes **no recipient** - it pays `msg.sender` and sends the NFT there. - Cancelling does not clear a listing. It bumps a per-seller nonce, so a cancelled listing still reads back intact and only the nonce comparison reveals it. ## Addresses | Used by | Field | Address | |---|---|---| | `protocolData` | `marketplace` | `0x6b478209974bd27e6cf661fef86c68072b0d6738` | Used for discovery, not in `protocolData`: | Contract | Address | |---|---| | veAERO | `0xeBf418Fe2512e7E6bd9b87a8F0f294aCDC67e6B4` | | AERO | `0x940181a94A35A4569E4529A3CDfB74e38FD98631` | | Offers | `0x2903ee1a9dc4C8230651004D11f733787A0f69c4` | Offers are a separate contract and are not zappable - only listings are. ``` marketplace.listingsLength() -> the listing book is an array marketplace.listings(listingId) -> seller, sellerNftNonce, nftCollection, nftId, currency, slopeMax, price, slopeDuration, fixedDuration, endTime, soldTime marketplace.listingPrice(listingId) -> price now, decayed if Dutch marketplace.sellerNftNonce(seller, collection, nftId) -> bumped by a cancel ``` A listing is buyable when `soldTime == 0`, `endTime >= now`, and `sellerNftNonce` still equals the `sellerNftNonce` stored on the listing. ## lpType mapping | What the user pays with | `lpType` | Seed address | |---|---|---| | Any other token, or native | `single` | listing `currency` -> `toToken` | | The listing's currency already | `none` | - | `single` is the normal case: it swaps the whole input into the listing's currency. `toToken` must be that currency - the API rejects anything else rather than letting it revert on chain. ## `protocolData` ```json { "protocol": "Vexy", "marketplace": "0x6b478209974bd27e6cf661fef86c68072b0d6738", "listingId": 9770 } ``` `listingId` is the array index, the same number `buyListing` takes. ## Sizing `fromAmount` There is no reverse quote - nothing converts "9845.6 AERO" into "how much WETH". The caller sizes the input, and the API validates it: - Size with an **estimate**: `listingPrice x usd(currency) / usd(payToken)`, plus a margin. The margin has to cover the 0.2% protocol fee, the route spread, and the slippage the quote is judged on. About 1% clears at `"slippage": 0.5`; do not go below the slippage itself. - The API validates with the **guarantee**: it compares the swap's `toAmountMin` against the price and rejects the quote when it falls short, rather than shipping a route whose worst case reverts. Overshooting is cheap and undershooting is not fatal: the whole input is swapped, the marketplace pulls exactly `listingPrice`, and the surplus currency is returned to `recipient`. The change comes back in the **listing's currency**, not in what the user paid with. `listingPrice` at quote time is a ceiling - a Dutch listing can only get cheaper before the transaction lands, never dearer - so no headroom is needed for the price itself. ## Worked example - native ETH into a listed veAERO Only the request objects; approve and zap exactly as the base guide does. ```ts const listing = await marketplace.listings(listingId) const price = await marketplace.listingPrice(listingId) // decayed, if Dutch const costUsd = Number(formatUnits(price, 18)) * usdPerAero const fromAmount = parseEther(String((costUsd * 1.01) / usdPerEth)) const lpData = { lpType: 'single', fromToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', // native ETH, nothing to approve fromAmount: fromAmount.toString(), toToken: listing.currency, // the listing decides this slippage: 0.5, } const protocolData = { protocol: 'Vexy', marketplace: '0x6b478209974bd27e6cf661fef86c68072b0d6738', listingId, } ``` ## Reading the response | Field | Value | |---|---| | `lpQuote.lpAddress` | the listing's currency - what the swap produced | | `protocolQuote` | `{ "protocol": "Vexy", "listingId": 9770, "price": "9845602671603889000000" }` | - `price` is what the buy will cost, in the currency's own units, at quote time. - The **veNFT goes to `user`**, not to `recipient`. The router receives it from `buyListing` and forwards it in the same transaction, and it forwards it to the address the router pulled payment from - the one address that cannot be a stranding typo. Leftover currency still follows `recipient`. - The NFT is never an output, so it does not appear in `outputs` and no token id is returned. Read it from the `BuyListing` event in the receipt, or from `listings(listingId).nftId`. - The currency output carries no minimum, because the protocol step spends it. The bound that survives is stronger: `buyListing` pulls the whole price or the transaction reverts. ## Errors | Message | Cause | |---|---| | `... has no listing ... on this chain` | Unknown `listingId`, or `marketplace` is not the marketplace. | | `Listing ... is already sold` | `soldTime != 0` - someone bought it first. | | `Listing ... is expired` | `endTime` has passed. | | `Listing ... was cancelled` | The seller's nonce moved on from the one on the listing. | | `Listing ... is priced in X, but the zap produces Y` | `toToken` is not the listing's currency. | | `Listing ... costs N of X, but this zap delivers at least M` | `fromAmount` too small - raise it. | A listing bought out from under the user between quote and send reverts with `Marketplace: Listing was sold`. The user loses gas and nothing else.