import { Commitment, ComputeBudgetProgram, ConfirmOptions, Connection, Keypair, PublicKey, SystemProgram, Transaction, } from "@solana/web3.js"; import { PROGRAM_ID as MPL_PROGRAM_ID } from "@metaplex-foundation/mpl-token-metadata"; import { createSyncNativeInstruction, createAssociatedTokenAccountIdempotentInstruction, getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID, } from "@solana/spl-token"; import { AnchorProvider, BN, Program, Wallet, web3, utils, Idl, } from "@coral-xyz/anchor"; import bs from "black-scholes"; import { dipMmIdl, dualMarketIdl } from "./idl"; import { fetchPythPrices, mintToTokenName, toBeBytes } from "./utils"; import { DIP_STATE_SIZE, DUAL_MARKET_PK, MM_BID_LIMIT, MIN_DIP_TIME_REMAINING_MS, MS_PER_YEAR, VOL_MAP, COLLATERAL_PK, USDC_ATOMS_PER_TOKEN, RF_RATE, PYTH_MAINNET_PKS, DIP_MM_PK, PREMIUM_USDC_ACCOUNT_SEED, OPTION_VAULT_PK, MINT_TO_PYTH, WSOL_PK, USDC_DEV_MINT_PK, PYTH_SOL_DEV_PK, DIP_MM_PRICING_SEED, MIN_APY, MAX_APY, NO_ORACLE_PRICE, NUM_SPL_ATOMS_PER_TOKEN, DUAL_MINT_PK, DAO_USDC_PK, DIP_MM_DMS_PLACEHOLDER_PK, MM_WALLET_PK, PREMIUM_TOKEN_ACCOUNT_PK, DUAL_PRICE, } from "./constants"; import { Dip, DipType, MmPrice, ParsedDipState, PricingSource } from "./types"; import { claimMint, optionMint, pricing, quoteVault, state, vault, } from "./addresses"; import { StakingOptions, STAKING_OPTIONS_PK, } from "@dual-finance/staking-options"; import { parsePriceData } from "@pythnetwork/client"; /** * API class with functions to interact with the DIP Program using Solana Web3 JS API. */ export class DIP { private connection: Connection; private program: Program; private commitment: Commitment; /** * Create a DIP helper object. * * @param rpcUrl The solana cluster endpoint used for the connecton. * @param commitment Commitment level for RPCs. Defaults to finalized. */ constructor(rpcUrl: string, commitment: Commitment = "finalized") { this.commitment = commitment; this.connection = new Connection(rpcUrl, this.commitment); const opts: ConfirmOptions = { preflightCommitment: this.commitment, commitment: this.commitment, }; // Public key and payer can be anything since this does not send transactions. const wallet: Wallet = { publicKey: DUAL_MARKET_PK, signAllTransactions: async (txs) => txs, signTransaction: async (tx) => tx, payer: new Keypair(), }; const provider = new AnchorProvider(this.connection, wallet, opts); this.program = new Program(dualMarketIdl, DUAL_MARKET_PK, provider); } /** * Deposit to the premium account. A normal token transfer can be used instead * of this. This is just a simplification since wallets dont always make it * easy to send to a PDA. * * @param authority Wallet where the USDC is coming from, must be signer. * @param usdcMint USDC mint which depends on the network used. * @param numPremiumTokenAtoms Premium amount. */ public async depositPremiumsInstruction( authority: PublicKey, usdcMint: PublicKey, numPremiumTokenAtoms: number ): Promise { const [premiumUsdcTokenAccount, _premiumUsdcTokenAccountBump] = PublicKey.findProgramAddressSync( [ Buffer.from(utils.bytes.utf8.encode(PREMIUM_USDC_ACCOUNT_SEED)), usdcMint.toBuffer(), ], this.program.programId ); return this.program.methods .depositPremiums(new BN(numPremiumTokenAtoms)) .accounts({ authority, premiumUsdcTokenAccount, usdcMint, tokenProgram: TOKEN_PROGRAM_ID, systemProgram: web3.SystemProgram.programId, }) .instruction(); } /** * Deposits specified amount to a premium account. * * @param authority Wallet where the USDC is coming from, must be signer. * @param usdcMint USDC mint which depends on the network used. * @param numPremiumTokenAtoms Premium amount. * @returns Transaction object with instruction for depositing premium. */ public async depositPremiumTransaction( authority: PublicKey, usdcMint: PublicKey, numPremiumTokenAtoms: number ): Promise { const userPremiumTokenAccount = getAssociatedTokenAddressSync( usdcMint, authority ); return await this.program.methods .depositPremiums(new BN(Math.floor(numPremiumTokenAtoms))) .accounts({ authority, premiumUsdcTokenAccount: PREMIUM_TOKEN_ACCOUNT_PK, userUsdcTokenAccount: userPremiumTokenAccount, usdcMint, tokenProgram: TOKEN_PROGRAM_ID, systemProgram: web3.SystemProgram.programId, }) .transaction(); } /** * Initializes a DIP along with related mm account * * @param authority Wallet paying rent. Restricted to team wallets for now. * @param strikeAtomsPerBaseToken Strike price for the DIP. * @param expirationSec Expiration of the DIP. * @param baseMint Mint for the token that the user is depositing. * @param quoteMint Mint for the token that the user could be swapped into. */ public async initDip( authority: PublicKey, strikeAtomsPerBaseToken: number, expirationSec: number, baseMint: PublicKey, quoteMint: PublicKey, displayStrike: number ) { const isUpside = COLLATERAL_PK.includes(baseMint.toString()); const asset = isUpside ? baseMint : quoteMint; // Name that will be registered with metaplex token metadata program. const tokenName = `${mintToTokenName(asset)}-${new Date( expirationSec * 1_000 ) .toISOString() .slice(0, 10)}@${displayStrike}${isUpside ? "C" : "P"}`; // Mint for token used to make claims. Also called short DIP. const vaultMint = claimMint( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); // Mint that is also considered the long DIP token. const optionMintAddress = optionMint( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const vaultBaseTokenAccount = vault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const vaultQuoteTokenAccount = quoteVault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const dipState = state( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); // Metadata accounts in metaplex for the short and long DIP mints. const [optionMintMetadataAccount] = PublicKey.findProgramAddressSync( [ Buffer.from(utils.bytes.utf8.encode("metadata")), MPL_PROGRAM_ID.toBuffer(), optionMintAddress.toBuffer(), ], MPL_PROGRAM_ID ); const [vaultMintMetadataAccount, _vaultMintMetadataBump] = PublicKey.findProgramAddressSync( [ Buffer.from(utils.bytes.utf8.encode("metadata")), MPL_PROGRAM_ID.toBuffer(), vaultMint.toBuffer(), ], MPL_PROGRAM_ID ); const mmOptionAccount = getAssociatedTokenAddressSync( optionMintAddress, MM_WALLET_PK ); const initDipIx = await this.program.methods .initDip( new BN(strikeAtomsPerBaseToken), new BN(expirationSec), tokenName ) .accounts({ authority, vaultMint, optionMint: optionMintAddress, vaultBaseTokenAccount, baseMint, vaultQuoteTokenAccount, quoteMint, dipState, optionMintMetadataAccount, vaultMintMetadataAccount, tokenMetadataProgram: MPL_PROGRAM_ID, systemProgram: web3.SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .instruction(); const transaction = new Transaction().add( // Request extra budget because there is a lot of initializing token accounts. ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000, }), initDipIx, createAssociatedTokenAccountIdempotentInstruction( authority, mmOptionAccount, MM_WALLET_PK, optionMintAddress ) ); return transaction; } /** * Deposit into a DIP with streaming prices. Because the DMS is based on a * streaming price, this instruction is not guaranteed to be valid for long. * * @param authority Signer of the instruction. * @param numTokensAtoms How many tokens to deposit. * @param strikeAtomsPerBaseToken Strike price for the DIP. * @param expirationSec Expiration of the DIP. * @param baseMint Mint for the token that the user is depositing. * @param quoteMint Mint for the token that the user could be swapped into. * @param usdcMint Usdc mint for premium token. Cannot necessarily be inferred from other inputs. */ public async depositStreamingInstruction( authority: PublicKey, numTokensAtoms: number, strikeAtomsPerBaseToken: number, expirationSec: number, baseMint: PublicKey, quoteMint: PublicKey, usdcMint: PublicKey ): Promise { // TODO: Possibly initialize token accounts for the caller and return a // transaction instead. const userBaseTokenAccount = getAssociatedTokenAddressSync( baseMint, authority ); // This is named usdc token account for legacy reasons, actually is quote. const userUsdcTokenAccount = getAssociatedTokenAddressSync( quoteMint, authority ); const vaultBaseTokenAccount = vault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const vaultTokenMint = claimMint( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const userVaultTokenAccount = getAssociatedTokenAddressSync( vaultTokenMint, authority ); const [premiumTokenAccount] = PublicKey.findProgramAddressSync( [ Buffer.from(utils.bytes.utf8.encode(PREMIUM_USDC_ACCOUNT_SEED)), usdcMint.toBuffer(), ], this.program.programId ); // Mint that is also considered the long DIP token. const optionMintAddress = optionMint( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const mmOptionTokenAccount = getAssociatedTokenAddressSync( optionMintAddress, OPTION_VAULT_PK ); const dipState = state( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); // Special case for wsol since it is the only token with the same devnet and // mainnet addresses. const pythPrice = baseMint.toBase58() == WSOL_PK.toBase58() && quoteMint.toBase58() == USDC_DEV_MINT_PK.toBase58() ? PYTH_SOL_DEV_PK : MINT_TO_PYTH[baseMint.toBase58()]; const [mmPricing] = PublicKey.findProgramAddressSync( [ Buffer.from(utils.bytes.utf8.encode(DIP_MM_PRICING_SEED)), toBeBytes(strikeAtomsPerBaseToken), toBeBytes(expirationSec), baseMint.toBuffer(), quoteMint.toBuffer(), ], DIP_MM_PK ); // Read what is on-chain to determine what the DMS should be. const dipMmProgram = new Program( dipMmIdl as Idl, DIP_MM_PK, this.program.provider ); // Default DMS. It parses, but might not match the bids. let mmDms: PublicKey = new PublicKey( "HyhjarjcywrvrpZN5BfafsLG6ejKMJMdPxhjs1ZxDKme" ); const mmPricingObj = await dipMmProgram.account.pricing.fetch( mmPricing.toBase58() ); if (mmPricingObj.bids.length) { const bidder: PublicKey = mmPricingObj.bids[0].account as PublicKey; const [deadManSwitch] = PublicKey.findProgramAddressSync( [Buffer.from(utils.bytes.utf8.encode("dms")), bidder.toBuffer()], DIP_MM_PK ); mmDms = deadManSwitch; } // TODO: Update this to use the new minPremium and LSO return this.program.methods .depositStreaming(new BN(numTokensAtoms), 0) .accounts({ authority, userBaseTokenAccount, vaultBaseTokenAccount, userVaultTokenAccount, vaultTokenMint, premiumTokenAccount, userUsdcTokenAccount, optionMint: optionMintAddress, mmOptionTokenAccount, pythPrice, dipState, mmPricing, mmDms, systemProgram: web3.SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .instruction(); } /** * Deposit into a DIP with streaming prices. This method sets up * associated token accounts, syncs native balance and leverages LSOs * if mint is available for provided parameters. * * @param authority Signer of the instruction. * @param amount How many tokens (in base atoms) to deposit. * @param strikeInQuoteAtomsPerBaseToken Strike price (in quote atoms per base token) of the DIP. * @param expirationInSeconds Expiration of the DIP in unix seconds. * @param baseMint Mint for the token being deposited. * @param quoteMint Mint for the token that could be swapped into. * @param premiumMint Mint for premium token. * @returns Transaction object with instructions needed to deposit into a DIP */ public async depositStreamingTransaction( authority: PublicKey, amount: number, strikeInQuoteAtomsPerBaseToken: number, expirationInSeconds: number, baseMint: PublicKey, quoteMint: PublicKey, premiumMint: PublicKey ): Promise { const transaction = new web3.Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000, }) ); const vaultBaseTokenAccount = vault( strikeInQuoteAtomsPerBaseToken, expirationInSeconds, baseMint, quoteMint ); // Vault token represents the short token const vaultTokenMint = claimMint( strikeInQuoteAtomsPerBaseToken, expirationInSeconds, baseMint, quoteMint ); const userBaseTokenAccount = getAssociatedTokenAddressSync( baseMint, authority ); const userPremiumTokenAccount = getAssociatedTokenAddressSync( premiumMint, authority ); const userVaultTokenAccount = getAssociatedTokenAddressSync( vaultTokenMint, authority ); if ( baseMint.toString() !== WSOL_PK.toString() && !(await this.connection.getAccountInfo(userBaseTokenAccount)) ) { throw new Error("no user base token account"); } // create ata account for the vault token if not found (short token) transaction.add( createAssociatedTokenAccountIdempotentInstruction( authority, userVaultTokenAccount, authority, vaultTokenMint ) ); // create ata account for the premium token if not found transaction.add( createAssociatedTokenAccountIdempotentInstruction( authority, userPremiumTokenAccount, authority, premiumMint ) ); // Wrap SOL if needed. if (baseMint.toBase58() === WSOL_PK.toBase58()) { let numExistingTokens = 0; try { const balance = await this.connection.getTokenAccountBalance( userBaseTokenAccount ); numExistingTokens = Number(balance.value.amount); } catch (err) { /* expected to error if ata doesn't exist */ } if (numExistingTokens < amount) { const amountToWrap = amount - numExistingTokens; transaction.add( createAssociatedTokenAccountIdempotentInstruction( authority, userBaseTokenAccount, authority, baseMint ), SystemProgram.transfer({ fromPubkey: authority, toPubkey: userBaseTokenAccount, lamports: amountToWrap, }) ); // Sync native to wrap the SOL transaction.add(createSyncNativeInstruction(userBaseTokenAccount)); } } const isUpside = COLLATERAL_PK.includes(baseMint.toBase58()); // Non stable mint, used for price fetching and determining vol const tokenMint = isUpside ? baseMint : quoteMint; // Special case for wsol since it is the only token with the same devnet and // mainnet addresses. const pythPrice = baseMint.toBase58() == WSOL_PK.toBase58() && quoteMint.toBase58() == USDC_DEV_MINT_PK.toBase58() ? PYTH_SOL_DEV_PK : new PublicKey(MINT_TO_PYTH[tokenMint.toBase58()]); let price = 0; try { const priceInfo = await this.connection.getAccountInfo(pythPrice); const priceData = priceInfo ? parsePriceData(priceInfo.data) : undefined; price = priceData?.price ? priceData.price : NO_ORACLE_PRICE; } catch (err) { console.log("Error getting pyth price", err); throw err; } // Used to determine backstop premium const vol = VOL_MAP[tokenMint.toBase58()]; // convert to ms and calculate diff between expiration timestamp and now const durationMs = expirationInSeconds * 1_000 - Date.now(); const fractionOfYear = durationMs / MS_PER_YEAR; const callOrPut = isUpside ? "call" : "put"; // Need to normalize to float strike for black scholes const strikeQuoteTokenPerBaseToken = isUpside ? strikeInQuoteAtomsPerBaseToken / NUM_SPL_ATOMS_PER_TOKEN[quoteMint.toString()] : Number( ( (1 / strikeInQuoteAtomsPerBaseToken) * NUM_SPL_ATOMS_PER_TOKEN[quoteMint.toString()] ).toPrecision(6) ); // Calculates backstop premium using black scholes const backstopPricePremiumAtoms = bs.blackScholes( price, strikeQuoteTokenPerBaseToken, fractionOfYear, vol, RF_RATE, callOrPut ) * USDC_ATOMS_PER_TOKEN; const dipMmProgram = new Program( dipMmIdl as Idl, DIP_MM_PK, this.program.provider ); const [mmPricing] = PublicKey.findProgramAddressSync( [ Buffer.from(utils.bytes.utf8.encode(DIP_MM_PRICING_SEED)), toBeBytes(strikeInQuoteAtomsPerBaseToken), toBeBytes(expirationInSeconds), baseMint.toBuffer(), quoteMint.toBuffer(), ], DIP_MM_PK ); // Determine premium based on max value between backstop and mm premium let mmDms = DIP_MM_DMS_PLACEHOLDER_PK; let expectedPremiumAtoms = backstopPricePremiumAtoms; try { const mmPricingAccount = await dipMmProgram.account.pricing.fetch( mmPricing.toBase58() ); // get mm price if there is an active bid if (mmPricingAccount.bids.length) { const bidder: PublicKey = mmPricingAccount.bids[0].account; const [deadManSwitch] = PublicKey.findProgramAddressSync( [Buffer.from(utils.bytes.utf8.encode("dms")), bidder.toBuffer()], DIP_MM_PK ); mmDms = deadManSwitch; const mmPriceAtoms = mmPricingAccount.bids.length ? mmPricingAccount.bids[0].price.toNumber() : NO_ORACLE_PRICE; if (mmPriceAtoms >= backstopPricePremiumAtoms) { expectedPremiumAtoms = mmPriceAtoms; } } } catch (err) { // if no bids are found, continue with backstop premium console.warn(err); } // protection to enable getting paid a minimum for deposit const minPremium = isUpside ? Math.floor( ((amount * expectedPremiumAtoms) / NUM_SPL_ATOMS_PER_TOKEN[baseMint.toBase58()]) * 0.9 ) : Math.floor( ((amount * expectedPremiumAtoms) / strikeQuoteTokenPerBaseToken / NUM_SPL_ATOMS_PER_TOKEN[baseMint.toBase58()]) * 0.9 ); // Calculate the LSO strike let lsoStrike = DUAL_PRICE; if (price) { const priceInAtoms = price * USDC_ATOMS_PER_TOKEN; lsoStrike = (1 + Math.abs( (isUpside ? strikeInQuoteAtomsPerBaseToken : (1 / strikeInQuoteAtomsPerBaseToken) * NUM_SPL_ATOMS_PER_TOKEN[quoteMint.toBase58()] * NUM_SPL_ATOMS_PER_TOKEN[baseMint.toBase58()]) - priceInAtoms ) / priceInAtoms) * DUAL_PRICE; lsoStrike = Math.floor((lsoStrike + 1_000) / 1_000) * 1_000; } const lsoName = `LSO-${expirationInSeconds}`; const soHelper = new StakingOptions(this.connection.rpcEndpoint); const soOptionMint = await soHelper.soMint( lsoStrike, lsoName, DUAL_MINT_PK ); const soState = await soHelper.state(lsoName, DUAL_MINT_PK); const soUserOptionAccount = getAssociatedTokenAddressSync( soOptionMint, authority ); const soOptionMintData = await this.connection.getAccountInfo(soOptionMint); // Option mint represents the long token const optionTokenMint = optionMint( strikeInQuoteAtomsPerBaseToken, expirationInSeconds, baseMint, quoteMint ); // Assumed to be initialized beforehand by the protocol const mmOptionTokenAccount = getAssociatedTokenAddressSync( optionTokenMint, OPTION_VAULT_PK ); const dipState = state( strikeInQuoteAtomsPerBaseToken, expirationInSeconds, baseMint, quoteMint ); // Check if the LSO Mint exists, if not, then fallback to the old deposit if (soOptionMintData !== null) { // create ata account for the so token if not found transaction.add( createAssociatedTokenAccountIdempotentInstruction( authority, soUserOptionAccount, authority, soOptionMint ) ); const [issueAuthority, issueAuthorityBump] = PublicKey.findProgramAddressSync( [ Buffer.from(utils.bytes.utf8.encode("LSO")), toBeBytes(expirationInSeconds), ], this.program.programId ); transaction.add( await this.program.methods .depositStreamingWithLso( new BN(Math.floor(amount)), issueAuthorityBump, new BN(minPremium) ) .accounts({ authority, userBaseTokenAccount, vaultBaseTokenAccount, userVaultTokenAccount, vaultTokenMint, premiumTokenAccount: PREMIUM_TOKEN_ACCOUNT_PK, userUsdcTokenAccount: userPremiumTokenAccount, daoUsdcTokenAccount: DAO_USDC_PK, optionTokenMint, mmOptionTokenAccount, pythPrice, dipState, mmPricing, mmDms, issueAuthority, soUserOptionAccount, soOptionMint, soState, stakingOptionsProgram: STAKING_OPTIONS_PK, systemProgram: SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .instruction() ); } else { transaction.add( await this.program.methods .depositStreaming(new BN(Math.floor(amount)), new BN(minPremium)) .accounts({ authority, userBaseTokenAccount, vaultBaseTokenAccount, userVaultTokenAccount, vaultTokenMint, premiumTokenAccount: PREMIUM_TOKEN_ACCOUNT_PK, userUsdcTokenAccount: userPremiumTokenAccount, daoUsdcTokenAccount: DAO_USDC_PK, optionTokenMint, mmOptionTokenAccount, pythPrice, mmPricing, dipState, mmDms, systemProgram: SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .instruction() ); } return transaction; } /** * Get an instruction to withdraw from an expired DIP * * @param authority Signer for the instruction. * @param strikeAtomsPerBaseToken Strike price for the DIP. * @param expirationSec Expiration of the DIP. * @param baseMint Mint for the token that the user is depositing. * @param quoteMint Mint for the token that the user could be swapped into. */ public async withdrawInstruction( authority: PublicKey, strikeAtomsPerBaseToken: number, expirationSec: number, baseMint: PublicKey, quoteMint: PublicKey ): Promise { const userBaseTokenAccount = getAssociatedTokenAddressSync( baseMint, authority ); const userQuoteTokenAccount = getAssociatedTokenAddressSync( quoteMint, authority ); const vaultBaseTokenAccount = vault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const vaultTokenMint = claimMint( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const userVaultTokenAccount = getAssociatedTokenAddressSync( vaultTokenMint, authority ); const vaultQuoteTokenAccount = quoteVault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const dipState = state( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); return this.program.methods .withdraw() .accounts({ authority, userBaseTokenAccount, vaultBaseTokenAccount, userVaultTokenAccount, vaultTokenMint, vaultQuoteTokenAccount, userQuoteTokenAccount, dipState, systemProgram: web3.SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .instruction(); } /** * Exercises a long DIP token. This method sets up associated token accounts, * syncs native balance if denominated in SOL and adds exercise instruction * all under one transaction. * * @param authority Signer for the transaction. * @param numTokensAtoms Amount in quote token atoms. * @param strikeAtomsPerBaseToken Strike price (in quote atoms per base token) of the DIP. * @param expirationSec Expiration of the DIP in unix seconds. * @param baseMint Mint for the token that mm swaps to. * @param quoteMint Mint for the token that mm swaps from. */ public async exerciseOptionsTransaction( authority: PublicKey, numTokensAtoms: number, strikeAtomsPerBaseToken: number, expirationSec: number, baseMint: PublicKey, quoteMint: PublicKey ): Promise { const vaultQuoteTokenAccount = quoteVault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const dipState = state( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const optionMintAddress = optionMint( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const mmOptionTokenAccount = getAssociatedTokenAddressSync( optionMintAddress, authority ); const mmQuoteTokenAccount = getAssociatedTokenAddressSync( quoteMint, authority ); const mmBaseTokenAccount = getAssociatedTokenAddressSync( baseMint, authority ); const vaultBaseTokenAccount = vault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const transaction = new web3.Transaction(); transaction.add( createAssociatedTokenAccountIdempotentInstruction( authority, mmBaseTokenAccount, authority, baseMint ) ); // Wrap SOL for downside exercise if needed. if (quoteMint.toBase58() === WSOL_PK.toBase58()) { let numExistingTokens = 0; try { const balance = await this.connection.getTokenAccountBalance( mmQuoteTokenAccount ); numExistingTokens = Number(balance.value.amount); } catch (err) { /* Getting balance could fail because account doesn't exist */ } const deliverableAmount = (numTokensAtoms * strikeAtomsPerBaseToken) / NUM_SPL_ATOMS_PER_TOKEN[baseMint.toBase58()]; if (numExistingTokens < deliverableAmount) { const amountToWrap = Math.ceil(deliverableAmount - numExistingTokens); // Transfer and sync native to wrap the SOL transaction.add( createAssociatedTokenAccountIdempotentInstruction( authority, mmQuoteTokenAccount, authority, quoteMint ), web3.SystemProgram.transfer({ fromPubkey: authority, toPubkey: mmQuoteTokenAccount, lamports: amountToWrap, }), createSyncNativeInstruction(mmQuoteTokenAccount) ); } } else { if (!(await this.connection.getAccountInfo(mmQuoteTokenAccount))) { throw new Error("no mm quote token account"); } } transaction.add( await this.program.methods .exerciseOption(new BN(numTokensAtoms)) .accounts({ authority, vaultQuoteTokenAccount, mmQuoteTokenAccount, mmOptionTokenAccount, optionMint: optionMintAddress, mmBaseTokenAccount, vaultBaseTokenAccount, dipState, systemProgram: SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .instruction() ); return transaction; } /** * Get an instruction to exercise a long DIP token. * * @param authority Signer for the instruction. * @param strikeAtomsPerBaseToken Strike price for the DIP. * @param expirationSec Expiration of the DIP. * @param baseMint Mint for the token that the user is depositing. * @param quoteMint Mint for the token that the user could be swapped into. */ public async exerciseOptionInstruction( authority: PublicKey, numTokensAtoms: number, strikeAtomsPerBaseToken: number, expirationSec: number, baseMint: PublicKey, quoteMint: PublicKey ): Promise { const vaultQuoteTokenAccount = quoteVault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const dipState = state( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const optionMintAddress = optionMint( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); const mmOptionTokenAccount = getAssociatedTokenAddressSync( optionMintAddress, OPTION_VAULT_PK ); const mmQuoteTokenAccount = getAssociatedTokenAddressSync( quoteMint, OPTION_VAULT_PK ); const mmBaseTokenAccount = getAssociatedTokenAddressSync( baseMint, OPTION_VAULT_PK ); const vaultBaseTokenAccount = vault( strikeAtomsPerBaseToken, expirationSec, baseMint, quoteMint ); return this.program.methods .exerciseOption(new BN(numTokensAtoms)) .accounts({ authority, vaultQuoteTokenAccount, mmQuoteTokenAccount, mmOptionTokenAccount, optionMint: optionMintAddress, mmBaseTokenAccount, vaultBaseTokenAccount, dipState, systemProgram: web3.SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .instruction(); } /** * Fetch all DIPs that are currently active and usable. Returns an object * which maps base token to an array of Dip objects. */ public async getActiveDips(): Promise<{ [pk: string]: Dip[] }> { const pricesByToken = await fetchPythPrices( this.connection, PYTH_MAINNET_PKS ); const fetchedMmPricing = await this.fetchMmPricing(); const allDipsByBaseToken: { [pk: string]: Dip[] } = COLLATERAL_PK.reduce( (acc, pk) => ({ ...acc, [pk]: [] }), {} ); const dipProgramAccounts = await this.connection.getProgramAccounts( DUAL_MARKET_PK, { filters: [{ dataSize: DIP_STATE_SIZE }], } ); for (const dipState of dipProgramAccounts) { const parsedDipState = this.parseDipState(dipState.account.data); const { expiration, baseMint, quoteMint } = parsedDipState; const durationMs = expiration * 1_000 - Date.now(); // Do not show any DIP that is less than 36 hours remaining. if (durationMs <= MIN_DIP_TIME_REMAINING_MS) { continue; } const isUpside = COLLATERAL_PK.includes(baseMint.toBase58()); // This is the token that is not the stable. const tokenMint = isUpside ? baseMint.toBase58() : quoteMint.toBase58(); // The strike is represented onchain as num quote atoms per base token. const strikeQuoteTokenPerBaseToken = isUpside ? parsedDipState.strike / USDC_ATOMS_PER_TOKEN : Number( ( (1 / parsedDipState.strike) * NUM_SPL_ATOMS_PER_TOKEN[quoteMint.toBase58()] ).toPrecision(6) ); const vol = VOL_MAP[tokenMint]; const volMax = vol * MM_BID_LIMIT; const currentTokenPrice = pricesByToken[tokenMint]; const fractionOfYear = durationMs / MS_PER_YEAR; const callOrPut = isUpside ? "call" : "put"; const backstopPriceUsdcAtoms = bs.blackScholes( currentTokenPrice, strikeQuoteTokenPerBaseToken, fractionOfYear, vol, RF_RATE, callOrPut ) * USDC_ATOMS_PER_TOKEN; // Max price is to prevent a market maker quoting an unreasonably high // price and that being used. This protects the market maker in case they // did not intend to outbid the backstop by so much as well as the // protocol since it holds some risk of not being able to sell at that // price if it was a mistake by the market maker. const maxPriceUsdcAtoms = bs.blackScholes( currentTokenPrice, strikeQuoteTokenPerBaseToken, fractionOfYear, volMax, RF_RATE, callOrPut ) * USDC_ATOMS_PER_TOKEN; // Use the strike from dipState since it is in full atoms. const pricingAccountAddress: PublicKey = await pricing( parsedDipState.strike, expiration, baseMint, quoteMint ); let pricingSource = PricingSource.backstop; let premiumUsdcAtoms = backstopPriceUsdcAtoms; // Repeated code try { // Assume that the prices are sorted from best to worst and that the DMS // is refreshed. const mmPricingObj = fetchedMmPricing[pricingAccountAddress.toBase58()]; const mmPrice = mmPricingObj && mmPricingObj.bids && mmPricingObj.bids.length ? mmPricingObj.bids[0].price.toNumber() : NO_ORACLE_PRICE; if (mmPrice <= maxPriceUsdcAtoms && mmPrice >= backstopPriceUsdcAtoms) { premiumUsdcAtoms = mmPrice; pricingSource = PricingSource.streaming; } } catch (err) { // Can fail if there is no pricing address. console.warn(err); } // TODO: Helper function for computing apy. We only need the final value. const earnedRatio = premiumUsdcAtoms / currentTokenPrice; const apr = earnedRatio / fractionOfYear / USDC_ATOMS_PER_TOKEN; const apy = (1 + apr * fractionOfYear) ** (1 / fractionOfYear) - 1; const isInvalidApy = apy < MIN_APY || apy > MAX_APY; if (isInvalidApy) { continue; } // Do not return an ITM DIP. It should already fail the APY check, but // this is to be safe. if ( (isUpside && strikeQuoteTokenPerBaseToken < currentTokenPrice) || (!isUpside && strikeQuoteTokenPerBaseToken > currentTokenPrice) ) { continue; } // How the date should be displayed in UI. const expirationString = new Date(expiration * 1_000) .toDateString() .split(" ") .slice(1) .join(" "); const dip: Dip = { key: `${expiration}${strikeQuoteTokenPerBaseToken}${tokenMint}`, expiration: expirationString, expirationInt: expiration, strike: strikeQuoteTokenPerBaseToken, upOrDown: isUpside ? DipType.upside : DipType.downside, pk: dipState.pubkey, apy, premium: premiumUsdcAtoms, baseMint, quoteMint, pricingSource, }; allDipsByBaseToken[tokenMint].push(dip); } return allDipsByBaseToken; } /** * Parse a DIPState using the idl. */ public parseDipState(buf: Buffer): ParsedDipState { const parsed = this.program.coder.accounts.decode("DIPState", buf); parsed.expiration = parsed.expiration.toNumber(); parsed.strike = parsed.strike.toNumber(); return parsed; } /** * Fetch all the onchain dipMm pricing accounts for active DIPs and return an * MmPrice object. */ private async fetchMmPricing(): Promise { const dipProgramAccounts = await this.connection.getProgramAccounts( DUAL_MARKET_PK, { filters: [{ dataSize: DIP_STATE_SIZE }], } ); const pricingAccountAddresses = []; for (const dipState of dipProgramAccounts) { const parsedDipState = this.parseDipState(dipState.account.data); const { strike, expiration, baseMint, quoteMint } = parsedDipState; // This is redundant with the filtering in the caller, but is done to // reduce the RPC load. const durationMs = expiration * 1_000 - Date.now(); if (durationMs <= MIN_DIP_TIME_REMAINING_MS) { continue; } const pricingAccountAddress = await pricing( strike, expiration, baseMint, quoteMint ); pricingAccountAddresses.push(pricingAccountAddress.toBase58()); } const dipMmProgram = new Program( dipMmIdl, DIP_MM_PK, this.program.provider ); const pricingAccounts = await dipMmProgram.account.pricing.fetchMultiple( pricingAccountAddresses ); return Object.fromEntries( pricingAccountAddresses.map((k, i) => [k, pricingAccounts[i]]) ) as MmPrice; } }