UNPKG

@agoric/zoe

Version:

Zoe: the Smart Contract Framework for Offer Enforcement

405 lines (358 loc) • 13.4 kB
import { assert } from '@endo/errors'; import { Far } from '@endo/marshal'; import { AmountMath, isNatValue } from '@agoric/ertp'; // Eventually will be importable from '@agoric/zoe-contract-support' import { getInputPrice, getOutputPrice, calcLiqValueToMint, calcValueToRemove, assertProposalShape, assertNatAssetKind, calcSecondaryRequired, } from '../contractSupport/index.js'; /** * @import {OfferHandler, ZCF} from '@agoric/zoe'; */ /** * Autoswap is a rewrite of Uniswap. Please see the documentation for * more https://agoric.com/documentation/zoe/guide/contracts/autoswap.html * * When the contract is instantiated, the two tokens (Central and Secondary) are * specified in the issuerKeywordRecord. There is no behavioral difference * between the two when trading; the names were chosen for consistency with our * constant product AMM. When trading, use the keywords In and Out to specify * the amount to be paid in, and the amount to be received. * * When adding or removing liquidity, the amounts deposited must be in * proportion to the current balances in the pool. The amount of the Central * asset is used as the basis. The Secondary assets must be added in proportion. * If less Secondary is provided than required, we refuse the offer. If more is * provided than is required, we return the excess. * * Before trading can take place, it is necessary to add liquidity using * makeAddLiquidityInvitation(). Separate invitations are available for adding * and removing liquidity, and for doing swaps. Other API operations support * price checks and checking the size of the liquidity pool. * * The swap operation requires either the input amount or the output amount to * be specified. makeSwapInInvitation treats the give amount as definitive, * while makeSwapOutInvitation honors the want amount. With swapIn, a want * amount can be specified, and if the offer can't be satisfied, the offer will * be refunded. Similarly with swapOut, the want amount will be satisfied if * possible. If more is provided as the give amount than necessary, the excess * will be refunded. If not enough is provided, the offer will be refunded. * * The publicFacet can make new invitations (makeSwapInInvitation, * makeSwapOutInvitation, makeAddLiquidityInvitation, and * makeRemoveLiquidityInvitation), tell how much would be paid for a given input * (getInputPrice), or how much would be earned by depositing a specified amount * (getOutputPrice). In addition, there are requests for the LiquidityIssuer * (getLiquidityIssuer), the current outstanding liquidity (getLiquiditySupply), * and the current balances in the pool (getPoolAllocation). * * @param {ZCF} zcf */ const start = async zcf => { // Create a local liquidity mint and issuer. const liquidityMint = await zcf.makeZCFMint('Liquidity'); // AWAIT //////////////////// const { issuer: liquidityIssuer, brand: liquidityBrand } = liquidityMint.getIssuerRecord(); let liqTokenSupply = 0n; // In order to get all the brands, we must call zcf.getTerms() after // we create the liquidityIssuer const { brands } = zcf.getTerms(); for (const brand of Object.values(brands)) { assertNatAssetKind(zcf, brand); } /** @type {Map<Brand,Keyword>} */ const brandToKeyword = new Map( Object.entries(brands).map(([keyword, brand]) => [brand, keyword]), ); /** * @param {Brand} brand * @returns {string} */ const getPoolKeyword = brand => { assert(brandToKeyword.has(brand), 'getPoolKeyword: brand not found'); const keyword = brandToKeyword.get(brand); assert.typeof(keyword, 'string'); return keyword; }; const { zcfSeat: poolSeat } = zcf.makeEmptySeatKit(); const getPoolAmount = brand => { const keyword = getPoolKeyword(brand); return poolSeat.getAmountAllocated(keyword, brand); }; function consummate(tradeAmountIn, tradeAmountOut, swapSeat) { zcf.atomicRearrange( harden([ [ swapSeat, poolSeat, { In: tradeAmountIn }, { [getPoolKeyword(tradeAmountIn.brand)]: tradeAmountIn }, ], [ poolSeat, swapSeat, { [getPoolKeyword(tradeAmountOut.brand)]: tradeAmountOut }, { Out: tradeAmountOut }, ], ]), ); swapSeat.exit(); return `Swap successfully completed.`; } const assertSwapProposal = seat => assertProposalShape(seat, { want: { Out: null }, give: { In: null }, }); /** * Swap one asset for another. In specifies the asset being provided and Out * specifies the wanted asset. The amount of Out returned is calculated based * on the In amount. If the calculation produces a value less than the * specified want, the trade will fail in offer safety. * * @type {OfferHandler} */ const swapInHandler = swapSeat => { assertSwapProposal(swapSeat); assert( !AmountMath.isEmpty(getPoolAmount(brands.Central)), `Pool not initialized`, ); const { give: { In: amountIn }, want: { Out: wantedAmountOut }, } = swapSeat.getProposal(); assert(isNatValue(amountIn.value)); const outputValue = getInputPrice( amountIn.value, getPoolAmount(amountIn.brand).value, getPoolAmount(wantedAmountOut.brand).value, ); const tradeAmountOut = AmountMath.make(wantedAmountOut.brand, outputValue); return consummate(amountIn, tradeAmountOut, swapSeat); }; /** * Swap one asset for another. In specifies the asset being provided and Out * specifies the wanted asset. The In amount is calculated based on the Out * amount. If the In amount provided is insufficient the trade is refused. * * @type {OfferHandler} */ const swapOutHandler = swapSeat => { assertSwapProposal(swapSeat); assert( !AmountMath.isEmpty(getPoolAmount(brands.Central)), 'Pool not initialized', ); const { give: { In: amountIn }, want: { Out: wantedAmountOut }, } = swapSeat.getProposal(); assert(isNatValue(wantedAmountOut.value)); assert.typeof(amountIn.value, 'bigint'); const tradePrice = getOutputPrice( wantedAmountOut.value, getPoolAmount(amountIn.brand).value, getPoolAmount(wantedAmountOut.brand).value, ); assert(tradePrice <= amountIn.value, 'amountIn insufficient'); const tradeAmountIn = AmountMath.make(amountIn.brand, tradePrice); return consummate(tradeAmountIn, wantedAmountOut, swapSeat); }; const addLiquidity = (seat, secondaryAmount) => { const userAllocation = seat.getCurrentAllocation(); const centralPool = getPoolAmount(brands.Central).value; assert(!AmountMath.isEmpty(userAllocation.Central), 'Pool is empty'); const centralIn = userAllocation.Central.value; const liquidityValueOut = calcLiqValueToMint( liqTokenSupply, centralIn, /** @type {bigint} */ (centralPool), ); const liquidityAmountOut = AmountMath.make( liquidityBrand, liquidityValueOut, ); liquidityMint.mintGains( harden({ Liquidity: liquidityAmountOut }), poolSeat, ); liqTokenSupply += liquidityValueOut; const liquidityDeposited = { Central: AmountMath.make(brands.Central, centralIn), Secondary: secondaryAmount, }; zcf.atomicRearrange( harden([ [seat, poolSeat, liquidityDeposited], [poolSeat, seat, { Liquidity: liquidityAmountOut }], ]), ); seat.exit(); return 'Added liquidity.'; }; const initiateLiquidity = liqSeat => { const userAllocation = liqSeat.getCurrentAllocation(); return addLiquidity(liqSeat, userAllocation.Secondary); }; /** * Add liquidity. We use the amount of the Central asset as the basis, and * require that Secondary assets be added in proportion. If less Secondary is * provided than required, we refuse the offer. If more is provided than is * required, we return the excess. * * If this is the first time liquidity was added, we accept all of both * Primary and Secondary, to establish the initial trading ratio. In this * case, we create liquidity equal to the value of Central asset contributed. * * @type {OfferHandler} */ const addLiquidityHandler = liqSeat => { assertProposalShape(liqSeat, { give: { Central: null, Secondary: null }, want: { Liquidity: null }, }); if (AmountMath.isEmpty(getPoolAmount(brands.Central))) { return initiateLiquidity(liqSeat); } const userAllocation = liqSeat.getCurrentAllocation(); const secondaryIn = userAllocation.Secondary; assert(isNatValue(userAllocation.Central.value)); assert(isNatValue(secondaryIn.value)); // To calculate liquidity, we'll need to calculate alpha from the primary // token's value before, and the value that will be added to the pool const secondaryOut = AmountMath.make( secondaryIn.brand, calcSecondaryRequired( userAllocation.Central.value, getPoolAmount(brands.Central).value, getPoolAmount(brands.Secondary).value, secondaryIn.value, ), ); // Central was specified precisely so offer must provide enough secondary. assert( AmountMath.isGTE(secondaryIn, secondaryOut), 'insufficient Secondary deposited', ); return addLiquidity(liqSeat, secondaryOut); }; /** @type {OfferHandler} */ const removeLiquidityHandler = removeLiqSeat => { assertProposalShape(removeLiqSeat, { want: { Central: null, Secondary: null }, give: { Liquidity: null }, }); // TODO (hibbert) should we burn tokens? const userAllocation = /** @type {{Liquidity: Amount<'nat'>}} */ ( removeLiqSeat.getCurrentAllocation() ); const liquidityIn = userAllocation.Liquidity; assert(!AmountMath.isEmpty(liquidityIn), 'Pool is empty'); const liquidityValueIn = liquidityIn.value; assert(isNatValue(liquidityValueIn)); const newUserCentralAmount = AmountMath.make( brands.Central, calcValueToRemove( liqTokenSupply, getPoolAmount(brands.Central).value, liquidityValueIn, ), ); const newUserSecondaryAmount = AmountMath.make( brands.Secondary, calcValueToRemove( liqTokenSupply, getPoolAmount(brands.Secondary).value, liquidityValueIn, ), ); liqTokenSupply -= liquidityValueIn; const liquidityRemoved = { Central: newUserCentralAmount, Secondary: newUserSecondaryAmount, }; zcf.atomicRearrange( harden([ [removeLiqSeat, poolSeat, { Liquidity: userAllocation.Liquidity }], [poolSeat, removeLiqSeat, liquidityRemoved], ]), ); removeLiqSeat.exit(); return 'Liquidity successfully removed.'; }; const makeAddLiquidityInvitation = () => zcf.makeInvitation(addLiquidityHandler, 'autoswap add liquidity'); const makeRemoveLiquidityInvitation = () => zcf.makeInvitation(removeLiquidityHandler, 'autoswap remove liquidity'); const makeSwapInInvitation = () => zcf.makeInvitation(swapInHandler, 'autoswap swap'); const makeSwapOutInvitation = () => zcf.makeInvitation(swapOutHandler, 'autoswap swap'); /** * `getOutputForGivenInput` calculates the result of a trade, given a certain * amount of digital assets in. * * @param {Amount<'nat'>} amountIn - the amount of digital * assets to be sent in * @param {Brand<'nat'>} brandOut - The brand of asset desired */ const getOutputForGivenInput = (amountIn, brandOut) => { const inputReserve = getPoolAmount(amountIn.brand).value; const outputReserve = getPoolAmount(brandOut).value; assert(isNatValue(amountIn.value)); if (AmountMath.isEmpty(amountIn)) { return AmountMath.makeEmpty(brandOut); } const outputValue = getInputPrice( amountIn.value, inputReserve, outputReserve, ); return AmountMath.make(brandOut, outputValue); }; /** * `getInputForGivenOutput` calculates the amount of assets required to be * provided in order to obtain a specified gain. * * @param {Amount<'nat'>} amountOut - the amount of digital assets desired * @param {Brand<'nat'>} brandIn - The brand of asset desired */ const getInputForGivenOutput = (amountOut, brandIn) => { const inputReserve = getPoolAmount(brandIn).value; const outputReserve = getPoolAmount(amountOut.brand).value; assert(isNatValue(amountOut.value)); if (AmountMath.isEmpty(amountOut)) { return AmountMath.makeEmpty(brandIn); } const outputValue = getOutputPrice( amountOut.value, inputReserve, outputReserve, ); return AmountMath.make(brandIn, outputValue); }; const getPoolAllocation = () => poolSeat.getCurrentAllocation(); /** @type {AutoswapPublicFacet} */ const publicFacet = Far('publicFacet', { getInputPrice: getOutputForGivenInput, getOutputPrice: getInputForGivenOutput, getLiquidityIssuer: () => liquidityIssuer, getLiquiditySupply: () => liqTokenSupply, getPoolAllocation, makeSwapInvitation: makeSwapInInvitation, makeSwapInInvitation, makeSwapOutInvitation, makeAddLiquidityInvitation, makeRemoveLiquidityInvitation, }); return harden({ publicFacet }); }; harden(start); export { start };