diff --git a/src/api/types/LaunchpadDtos.ts b/src/api/types/LaunchpadDtos.ts index 0d591ec..c2bea99 100644 --- a/src/api/types/LaunchpadDtos.ts +++ b/src/api/types/LaunchpadDtos.ts @@ -125,6 +125,12 @@ export class CreateTokenSaleDTO extends SubmitCallDTO { @Type(() => ReverseBondingCurveConfigurationDto) public reverseBondingCurveConfiguration?: ReverseBondingCurveConfigurationDto; + @IsOptional() + @IsNumber() + @Min(100) + @Max(100) + public adjustableSupplyMultiplier?: number; + constructor( tokenName: string, tokenSymbol: string, @@ -134,7 +140,8 @@ export class CreateTokenSaleDTO extends SubmitCallDTO { tokenCollection: string, tokenCategory: string, reverseBondingCurveConfiguration?: ReverseBondingCurveConfigurationDto, - saleStartTime?: number + saleStartTime?: number, + adjustableSupplyMultiplier?: number ) { super(); this.tokenName = tokenName; @@ -149,6 +156,10 @@ export class CreateTokenSaleDTO extends SubmitCallDTO { if (saleStartTime !== undefined) { this.saleStartTime = saleStartTime; } + + if (adjustableSupplyMultiplier !== undefined) { + this.adjustableSupplyMultiplier = adjustableSupplyMultiplier; + } } } diff --git a/src/api/types/LaunchpadSale.ts b/src/api/types/LaunchpadSale.ts index ecb0eb2..4597cac 100644 --- a/src/api/types/LaunchpadSale.ts +++ b/src/api/types/LaunchpadSale.ts @@ -23,7 +23,16 @@ import { } from "@gala-chain/api"; import BigNumber from "bignumber.js"; import { Exclude, Type } from "class-transformer"; -import { IsInt, IsNotEmpty, IsOptional, IsPositive, IsString, ValidateNested } from "class-validator"; +import { + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + Min, + ValidateNested +} from "class-validator"; import { JSONSchema } from "class-validator-jsonschema"; import { ReverseBondingCurveConfigurationChainObject } from "./LaunchpadDtos"; @@ -93,6 +102,15 @@ export class LaunchpadSale extends ChainObject { @Type(() => ReverseBondingCurveConfigurationChainObject) public reverseBondingCurveConfiguration?: ReverseBondingCurveConfigurationChainObject; + /** + * Optional multiplier used to scale token output (both in bonding curve calculations and initial + * minting) while preserving the same economics and curve shape. + */ + @IsOptional() + @IsNumber() + @Min(0) + public adjustableSupplyMultiplier?: number; + @JSONSchema({ description: "The market cap has been calculated using the bonding curve equations to approximate a specific final price." @@ -121,7 +139,8 @@ export class LaunchpadSale extends ChainObject { sellingToken: TokenInstanceKey, reverseBondingCurveConfiguration: ReverseBondingCurveConfigurationChainObject | undefined, saleOwner: UserAlias, - saleStartTime?: number | undefined + saleStartTime?: number | undefined, + adjustableSupplyMultiplier?: number ) { super(); @@ -129,7 +148,6 @@ export class LaunchpadSale extends ChainObject { this.saleOwner = saleOwner; this.sellingToken = sellingToken; - this.sellingTokenQuantity = "1e+7"; if (saleStartTime) { this.saleStartTime = saleStartTime; @@ -141,9 +159,19 @@ export class LaunchpadSale extends ChainObject { this.saleStatus = SaleStatus.ONGOING; } - this.basePrice = new BigNumber(LaunchpadSale.BASE_PRICE); - this.exponentFactor = new BigNumber("1166069000000"); - this.maxSupply = new BigNumber("1e+7"); + if (adjustableSupplyMultiplier !== undefined) { + this.adjustableSupplyMultiplier = adjustableSupplyMultiplier; + this.basePrice = new BigNumber(LaunchpadSale.BASE_PRICE).dividedBy(adjustableSupplyMultiplier); + this.exponentFactor = new BigNumber("1166069000000"); + this.maxSupply = new BigNumber("1e+7").times(adjustableSupplyMultiplier); + this.sellingTokenQuantity = new BigNumber("1e+7").times(adjustableSupplyMultiplier).toString(); + } else { + this.basePrice = new BigNumber(LaunchpadSale.BASE_PRICE); + this.exponentFactor = new BigNumber("1166069000000"); + this.maxSupply = new BigNumber("1e+7"); + this.sellingTokenQuantity = "1e+7"; + } + this.euler = new BigNumber("2.7182818284590452353602874713527"); const nativeTokenInstance = new TokenInstanceKey(); diff --git a/src/chaincode/launchpad/buyExactToken.spec.ts b/src/chaincode/launchpad/buyExactToken.spec.ts index 033b53a..89c471d 100644 --- a/src/chaincode/launchpad/buyExactToken.spec.ts +++ b/src/chaincode/launchpad/buyExactToken.spec.ts @@ -32,6 +32,7 @@ import { LaunchpadFeeConfig, LaunchpadSale, NativeTokenQuantityDto, + SaleStatus, TradeResDto } from "../../api/types"; import { LaunchpadContract } from "../LaunchpadContract"; @@ -304,4 +305,236 @@ describe("buyWithNative", () => { ) ); }); + + test("Full sale purchase yields expected totals for 10 Million token sale", async () => { + // Given + salelaunchpadGalaBalance = plainToInstance(TokenBalance, { + ...launchpadgala.tokenBalance(), + owner: vaultAddress, + quantity: new BigNumber("0") + }); + + saleCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: vaultAddress, + quantity: new BigNumber("2e+7") + }); + + userlaunchpadGalaBalance = plainToInstance(TokenBalance, { + ...launchpadgala.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("100000000") + }); + + userCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("0") + }); + + const launchpadConfig = new LaunchpadFeeConfig(users.testUser2.identityKey, Number("0.001"), [ + users.testUser2.identityKey + ]); + + const userStartingGalaQuantity = userlaunchpadGalaBalance.getQuantityTotal(); + + const { ctx, contract, getWrites } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadConfig, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + // When + for (let i = 0; i < 50; i++) { + const dto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("200000")); + + dto.uniqueKey = randomUniqueKey(); + dto.sign(users.testUser1.privateKey); + + const buyTokenRes = await contract.BuyExactToken(ctx, dto); + + expect(buyTokenRes).toEqual(transactionSuccess()); + } + + // Then + const saleKey = sale.getCompositeKey(); + const userGalaBalanceKey = userlaunchpadGalaBalance.getCompositeKey(); + const userMemeBalanceKey = userCurrencyBalance.getCompositeKey(); + + const writes = getWrites(); + + const finalSaleData = JSON.parse(writes[saleKey]); + const finalUserGalaBalanceData = JSON.parse(writes[userGalaBalanceKey]); + const finalUserMemeBalanceData = JSON.parse(writes[userMemeBalanceKey]); + + expect(finalSaleData).toEqual( + expect.objectContaining({ + saleStatus: SaleStatus.END + }) + ); + + const finalUserGalaQuantity = new BigNumber(finalUserGalaBalanceData.quantity); + const finalUserMemeQuantity = new BigNumber(finalUserMemeBalanceData.quantity); + + // user bought full sale quantity of ten million + expect(finalUserMemeQuantity).toEqual(new BigNumber("10000000")); + expect(userStartingGalaQuantity.minus(finalUserGalaQuantity)).toEqual(new BigNumber("1560577.53780865")); + }); + + test("Adjustable supply: single transaction", async () => { + // Given + const multiplier = 100; + + sale = new LaunchpadSale( + vaultAddress, + currencyInstance.instanceKeyObj(), + undefined, + users.testUser1.identityKey, + undefined, + multiplier + ); + + const { ctx, contract } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + const dto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("500").times(multiplier)); + + dto.uniqueKey = randomUniqueKey(); + dto.sign(users.testUser1.privateKey); + + const expectedResponse = plainToInstance(TradeResDto, { + inputQuantity: "0.00825575", + totalFees: "0", + totalTokenSold: new BigNumber("500").times(multiplier).toString(), + outputQuantity: new BigNumber("500").times(multiplier).toString(), + tokenName: "AUTOMATEDTESTCOIN", + tradeType: "Buy", + uniqueKey: dto.uniqueKey, + vaultAddress: "service|GALA$Unit$none$none$launchpad", + userAddress: "client|testUser1", + isFinalized: false, + functionName: "BuyExactToken" + }); + + // When + const buyTokenRes = await contract.BuyExactToken(ctx, dto); + + // Then + expect(buyTokenRes).toEqual(transactionSuccess(expectedResponse)); + }); + + test("Adjustable supply: Full sale purchase yields expected totals for 1 billion token sale", async () => { + // Given + const multiplier = 100; + + sale = new LaunchpadSale( + vaultAddress, + currencyInstance.instanceKeyObj(), + undefined, + users.testUser1.identityKey, + undefined, + multiplier + ); + + salelaunchpadGalaBalance = plainToInstance(TokenBalance, { + ...launchpadgala.tokenBalance(), + owner: vaultAddress, + quantity: new BigNumber("0") + }); + + saleCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: vaultAddress, + quantity: new BigNumber("2e+7").times(multiplier) + }); + + userlaunchpadGalaBalance = plainToInstance(TokenBalance, { + ...launchpadgala.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("100000000") + }); + + userCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: users.testUser1.identityKey, + quantity: new BigNumber("0") + }); + + const launchpadConfig = new LaunchpadFeeConfig(users.testUser2.identityKey, Number("0.001"), [ + users.testUser2.identityKey + ]); + + const userStartingGalaQuantity = userlaunchpadGalaBalance.getQuantityTotal(); + + const { ctx, contract, getWrites } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadConfig, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + // When + for (let i = 0; i < 50; i++) { + const dto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("200000").times(multiplier)); + + dto.uniqueKey = randomUniqueKey(); + dto.sign(users.testUser1.privateKey); + + const buyTokenRes = await contract.BuyExactToken(ctx, dto); + + expect(buyTokenRes).toEqual(transactionSuccess()); + } + + // Then + const saleKey = sale.getCompositeKey(); + const userGalaBalanceKey = userlaunchpadGalaBalance.getCompositeKey(); + const userMemeBalanceKey = userCurrencyBalance.getCompositeKey(); + + const writes = getWrites(); + + const finalSaleData = JSON.parse(writes[saleKey]); + const finalUserGalaBalanceData = JSON.parse(writes[userGalaBalanceKey]); + const finalUserMemeBalanceData = JSON.parse(writes[userMemeBalanceKey]); + + expect(finalSaleData).toEqual( + expect.objectContaining({ + saleStatus: SaleStatus.END + }) + ); + + const finalUserGalaQuantity = new BigNumber(finalUserGalaBalanceData.quantity); + const finalUserMemeQuantity = new BigNumber(finalUserMemeBalanceData.quantity); + + // user bought full sale quantity of ten million + expect(finalUserMemeQuantity).toEqual(new BigNumber("10000000").times(multiplier)); + expect(userStartingGalaQuantity.minus(finalUserGalaQuantity)).toEqual(new BigNumber("1560577.53780865")); + }); }); diff --git a/src/chaincode/launchpad/buyWithNative.spec.ts b/src/chaincode/launchpad/buyWithNative.spec.ts index 12e096d..516f4ad 100644 --- a/src/chaincode/launchpad/buyWithNative.spec.ts +++ b/src/chaincode/launchpad/buyWithNative.spec.ts @@ -347,8 +347,68 @@ describe("buyWithNative", () => { expect(difference.isLessThanOrEqualTo(tolerance)).toBe(true); } }); + + test("Adjustable supply: Single transaction", async () => { + //Given + const multiplier = 100; + + sale = new LaunchpadSale( + vaultAddress, + currencyInstance.instanceKeyObj(), + undefined, + users.testUser1.identityKey, + undefined, + multiplier + ); + + saleCurrencyBalance = plainToInstance(TokenBalance, { + ...currency.tokenBalance(), + owner: vaultAddress, + quantity: new BigNumber("2e+7").times(multiplier) + }); + + const { ctx, contract } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + const dto = new NativeTokenQuantityDto(vaultAddress, new BigNumber("150")); + + dto.uniqueKey = randomUniqueKey(); + dto.sign(users.testUser1.privateKey); + + const expectedOutput = new BigNumber("2101667.8890651635").times(multiplier).toString(); + const expectedResponse = plainToInstance(TradeResDto, { + inputQuantity: "150", + totalFees: "0", + totalTokenSold: expectedOutput, + outputQuantity: expectedOutput, + tokenName: "AUTOMATEDTESTCOIN", + tradeType: "Buy", + vaultAddress: "service|GALA$Unit$none$none$launchpad", + userAddress: "client|testUser1", + isFinalized: false, + functionName: "BuyWithNative", + uniqueKey: dto.uniqueKey + }); + + //When + const buyTokenRes = await contract.BuyWithNative(ctx, dto); + + //Then + expect(buyTokenRes).toEqual(transactionSuccess(expectedResponse)); + }); }); -function roundToDecimal(value, decimals) { +function roundToDecimal(value: number, decimals: number) { const factor = Math.pow(10, decimals); return Math.round(value * factor) / factor; } diff --git a/src/chaincode/launchpad/buyWithNative.ts b/src/chaincode/launchpad/buyWithNative.ts index c674dc7..d9c3a76 100644 --- a/src/chaincode/launchpad/buyWithNative.ts +++ b/src/chaincode/launchpad/buyWithNative.ts @@ -59,10 +59,14 @@ export async function buyWithNative( const memeToken = sale.fetchSellingTokenInstanceKey(); // If native tokens required exceeds the market cap, the sale can be finalized + const supplyCap = + sale.adjustableSupplyMultiplier !== undefined + ? new BigNumber(LaunchpadSale.MARKET_CAP).times(sale.adjustableSupplyMultiplier).toString() + : LaunchpadSale.MARKET_CAP; if ( buyTokenDTO.nativeTokenQuantity .plus(new BigNumber(sale.nativeTokenQuantity)) - .isGreaterThanOrEqualTo(new BigNumber(LaunchpadSale.MARKET_CAP)) + .isGreaterThanOrEqualTo(new BigNumber(supplyCap)) ) { isSaleFinalized = true; } diff --git a/src/chaincode/launchpad/callMemeTokenIn.ts b/src/chaincode/launchpad/callMemeTokenIn.ts index da9954b..e577a3d 100644 --- a/src/chaincode/launchpad/callMemeTokenIn.ts +++ b/src/chaincode/launchpad/callMemeTokenIn.ts @@ -30,15 +30,21 @@ function calculateMemeTokensRequired( sale: LaunchpadSale, requestedNativeTokenQuantity: BigNumber, nativeTokenDecimals: number, - sellingTokenDecimals: number + sellingTokenDecimals: number, + adjustableSupplyMultiplier?: number ): [string, string] { const totalTokensSold = new Decimal(sale.fetchTokensSold()); // current tokens sold / x let nativeTokens = new Decimal(requestedNativeTokenQuantity.toString()).toDecimalPlaces( nativeTokenDecimals, Decimal.ROUND_DOWN ); - const basePrice = new Decimal(LaunchpadSale.BASE_PRICE); // base price / a - const { exponentFactor, euler, decimals } = getBondingConstants(); + + const basePrice = + adjustableSupplyMultiplier && adjustableSupplyMultiplier > 0 + ? new Decimal(LaunchpadSale.BASE_PRICE).dividedBy(adjustableSupplyMultiplier) + : new Decimal(LaunchpadSale.BASE_PRICE); + + const { exponentFactor, euler, decimals } = getBondingConstants(adjustableSupplyMultiplier); const nativeTokenInVault = new Decimal(sale.nativeTokenQuantity); if (nativeTokens.greaterThan(nativeTokenInVault)) { @@ -90,7 +96,8 @@ export async function callMemeTokenIn( sale, sellTokenDTO.nativeTokenQuantity, nativeTokenDecimals, - sellingTokenDecimals + sellingTokenDecimals, + sale.adjustableSupplyMultiplier ); return { diff --git a/src/chaincode/launchpad/callMemeTokenOut.ts b/src/chaincode/launchpad/callMemeTokenOut.ts index dc27420..89c6650 100644 --- a/src/chaincode/launchpad/callMemeTokenOut.ts +++ b/src/chaincode/launchpad/callMemeTokenOut.ts @@ -29,15 +29,26 @@ function calculateTokensPurchasable( nativeTokens: Decimal, totalTokensSold: Decimal, nativeTokenDecimals: number, - sellingTokenDecimals: number + sellingTokenDecimals: number, + adjustableSupplyMultiplier?: number ): [string, string] { - const basePrice = new Decimal(LaunchpadSale.BASE_PRICE); - const { exponentFactor, euler, decimals } = getBondingConstants(); + const basePrice = + adjustableSupplyMultiplier && adjustableSupplyMultiplier > 0 + ? new Decimal(LaunchpadSale.BASE_PRICE).dividedBy(adjustableSupplyMultiplier) + : new Decimal(LaunchpadSale.BASE_PRICE); + + const { exponentFactor, euler, decimals } = getBondingConstants(adjustableSupplyMultiplier); // Round native tokens, then calculate tokens based on that rounded amount const roundedNativeTokens = nativeTokens.toDecimalPlaces(nativeTokenDecimals, Decimal.ROUND_UP); - // Calculate tokens purchasable: newTokens = (decimals / exponentFactor) * ln((nativeTokens * exponentFactor / basePrice) + e^(exponentFactor * totalTokensSold / decimals)) - totalTokensSold + // Calculate tokens purchasable: + // newTokens = (decimals / exponentFactor) * + // ln( + // (nativeTokens * exponentFactor / basePrice) + + // e^(exponentFactor * totalTokensSold / decimals) + // ) - + // totalTokensSold // Where: // constant = nativeTokens * exponentFactor / basePrice // exponent1 = exponentFactor * totalTokensSold / decimals @@ -55,9 +66,13 @@ function calculateTokensPurchasable( const result = lnEthScaledBase.minus(totalTokensSold); let roundedResult = result.toDecimalPlaces(sellingTokenDecimals, Decimal.ROUND_DOWN); - // Cap total supply to 10 million - if (roundedResult.add(totalTokensSold).greaterThan(new Decimal("1e+7"))) { - roundedResult = new Decimal("1e+7").minus(new Decimal(totalTokensSold)); + // Cap total supply + const supplyCap = adjustableSupplyMultiplier + ? new BigNumber(1e7).times(adjustableSupplyMultiplier).toString() + : "1e+7"; + + if (roundedResult.add(totalTokensSold).greaterThan(new Decimal(supplyCap))) { + roundedResult = new Decimal(supplyCap).minus(new Decimal(totalTokensSold)); } return [roundedNativeTokens.toFixed(), roundedResult.toFixed()]; @@ -119,7 +134,8 @@ export async function callMemeTokenOut( nativeTokens, totalTokensSold, nativeTokenDecimals, - sellingTokenDecimals + sellingTokenDecimals, + sale?.adjustableSupplyMultiplier ); // Fetch fee configuration and return result diff --git a/src/chaincode/launchpad/callNativeTokenIn.ts b/src/chaincode/launchpad/callNativeTokenIn.ts index 6d99f40..2d4db2d 100644 --- a/src/chaincode/launchpad/callNativeTokenIn.ts +++ b/src/chaincode/launchpad/callNativeTokenIn.ts @@ -29,15 +29,25 @@ function calculateNativeTokensRequired( tokensToBuy: Decimal, totalTokensSold: Decimal, sellingTokenDecimals: number, - nativeTokenDecimals: number + nativeTokenDecimals: number, + adjustableSupplyMultiplier?: number ): [string, string] { - const basePrice = new Decimal(LaunchpadSale.BASE_PRICE); - const { exponentFactor, euler, decimals } = getBondingConstants(); + const basePrice = + adjustableSupplyMultiplier && adjustableSupplyMultiplier > 0 + ? new Decimal(LaunchpadSale.BASE_PRICE).dividedBy(adjustableSupplyMultiplier) + : new Decimal(LaunchpadSale.BASE_PRICE); + + const { exponentFactor, euler, decimals } = getBondingConstants(adjustableSupplyMultiplier); // Round tokens first, then calculate native tokens based on that rounded amount const roundedTokensToBuy = tokensToBuy.toDecimalPlaces(sellingTokenDecimals, Decimal.ROUND_DOWN); - // Calculate native tokens required: price = (basePrice / exponentFactor) * (e^(exponentFactor * (totalTokensSold + tokensToBuy) / decimals) - e^(exponentFactor * totalTokensSold / decimals)) + // Calculate native tokens required: + // price = (basePrice / exponentFactor) * + // ( + // e^(exponentFactor * (totalTokensSold + tokensToBuy) / decimals) - + // e^(exponentFactor * totalTokensSold / decimals) + // ) // Where: // exponent1 = exponentFactor * (totalTokensSold + tokensToBuy) / decimals // exponent2 = exponentFactor * totalTokensSold / decimals @@ -81,13 +91,13 @@ export async function callNativeTokenIn( ctx: GalaChainContext, buyTokenDTO: ExactTokenQuantityDto ): Promise { - const sale = await fetchAndValidateSale(ctx, buyTokenDTO.vaultAddress); + const sale: LaunchpadSale = await fetchAndValidateSale(ctx, buyTokenDTO.vaultAddress); const totalTokensSold = new Decimal(sale.fetchTokensSold()); let tokensToBuy = new Decimal(buyTokenDTO.tokenQuantity.toString()); // Adjust tokensToBuy if user is trying to buy more tokens than the total supply - if (tokensToBuy.add(totalTokensSold).greaterThan(new Decimal("1e+7"))) { + if (tokensToBuy.add(totalTokensSold).greaterThan(new Decimal(sale.maxSupply.toString()))) { tokensToBuy = new Decimal(sale.sellingTokenQuantity); } @@ -99,7 +109,8 @@ export async function callNativeTokenIn( tokensToBuy, totalTokensSold, sellingTokenDecimals, - nativeTokenDecimals + nativeTokenDecimals, + sale.adjustableSupplyMultiplier ); const launchpadFeeAddressConfiguration = await fetchLaunchpadFeeAddress(ctx); diff --git a/src/chaincode/launchpad/callNativeTokenOut.ts b/src/chaincode/launchpad/callNativeTokenOut.ts index 3939213..ad7b502 100644 --- a/src/chaincode/launchpad/callNativeTokenOut.ts +++ b/src/chaincode/launchpad/callNativeTokenOut.ts @@ -29,13 +29,18 @@ function calculateNativeTokensReceived( sale: LaunchpadSale, tokensToSellBn: BigNumber, sellingTokenDecimals: number, - nativeTokenDecimals: number + nativeTokenDecimals: number, + adjustableSupplyMultiplier?: number ): [string, string] { const totalTokensSold = new Decimal(sale.fetchTokensSold()); let tokensToSell = new Decimal(tokensToSellBn.toString()); - const basePrice = new Decimal(LaunchpadSale.BASE_PRICE); - const { exponentFactor, euler, decimals } = getBondingConstants(); + const basePrice = + adjustableSupplyMultiplier && adjustableSupplyMultiplier > 0 + ? new Decimal(LaunchpadSale.BASE_PRICE).dividedBy(adjustableSupplyMultiplier) + : new Decimal(LaunchpadSale.BASE_PRICE); + + const { exponentFactor, euler, decimals } = getBondingConstants(adjustableSupplyMultiplier); let newTotalTokensSold = totalTokensSold.minus(tokensToSell); @@ -88,7 +93,8 @@ export async function callNativeTokenOut( sale, sellTokenDTO.tokenQuantity, sellingTokenDecimals, - nativeTokenDecimals + nativeTokenDecimals, + sale.adjustableSupplyMultiplier ); const launchpadFeeAddressConfiguration = await fetchLaunchpadFeeAddress(ctx); diff --git a/src/chaincode/launchpad/createSale.ts b/src/chaincode/launchpad/createSale.ts index f36bf9b..01c8761 100644 --- a/src/chaincode/launchpad/createSale.ts +++ b/src/chaincode/launchpad/createSale.ts @@ -86,6 +86,8 @@ export async function createSale( throw new ConflictError("This token and a sale associated with it already exists"); } + const supplyCapMultiplier = launchpadDetails.adjustableSupplyMultiplier ?? 1; + // Call createTokenClass await createTokenClass(ctx, { network: "GC", @@ -96,8 +98,8 @@ export async function createSale( symbol: launchpadDetails.tokenSymbol, description: launchpadDetails.tokenDescription, image: launchpadDetails.tokenImage, - maxSupply: new BigNumber("2e+7"), - maxCapacity: new BigNumber("2e+7"), + maxSupply: new BigNumber("2e+7").times(supplyCapMultiplier), + maxCapacity: new BigNumber("2e+7").times(supplyCapMultiplier), totalMintAllowance: new BigNumber(0), totalSupply: new BigNumber(0), totalBurned: new BigNumber(0), @@ -109,7 +111,7 @@ export async function createSale( tokenClassKey: tokenInstanceKey.getTokenClassKey(), tokenInstance: new BigNumber(0), owner: vaultAddress, - quantity: new BigNumber("2e+7") + quantity: new BigNumber("2e+7").times(supplyCapMultiplier) }); //Update token class to remove the calling user as an authority in the token class @@ -123,7 +125,9 @@ export async function createSale( vaultAddress, tokenInstanceKey, launchpadDetails.reverseBondingCurveConfiguration?.toChainObject(), - ctx.callingUser + ctx.callingUser, + undefined, + launchpadDetails.adjustableSupplyMultiplier ); await putChainObject(ctx, launchpad); diff --git a/src/chaincode/launchpad/finaliseSale.ts b/src/chaincode/launchpad/finaliseSale.ts index f9e5da2..9341d54 100644 --- a/src/chaincode/launchpad/finaliseSale.ts +++ b/src/chaincode/launchpad/finaliseSale.ts @@ -203,8 +203,8 @@ function calculateFinalLaunchpadPrice( areTokensSorted: boolean ): { sqrtPrice: BigNumber; finalPrice: BigNumber } { const totalTokensSold = new Decimal(sale.fetchTokensSold()); - const basePrice = new Decimal(LaunchpadSale.BASE_PRICE); - const { exponentFactor, euler, decimals } = getBondingConstants(); + const basePrice = new Decimal(sale.basePrice.toString()); + const { exponentFactor, euler, decimals } = getBondingConstants(sale.adjustableSupplyMultiplier); const exponent = exponentFactor.mul(totalTokensSold).div(decimals); const multiplicand = euler.pow(exponent); diff --git a/src/chaincode/launchpad/sellExactToken.spec.ts b/src/chaincode/launchpad/sellExactToken.spec.ts index 2807e3a..cd81095 100644 --- a/src/chaincode/launchpad/sellExactToken.spec.ts +++ b/src/chaincode/launchpad/sellExactToken.spec.ts @@ -21,11 +21,11 @@ import { asValidUserAlias, randomUniqueKey } from "@gala-chain/api"; -import { currency, fixture, users } from "@gala-chain/test"; +import { currency, fixture, transactionSuccess, users } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; -import { ExactTokenQuantityDto, LaunchpadSale } from "../../api/types"; +import { ExactTokenQuantityDto, LaunchpadSale, TradeResDto } from "../../api/types"; import { LaunchpadContract } from "../LaunchpadContract"; import launchpadgala from "../test/launchpadgala"; @@ -204,4 +204,141 @@ describe("sellExactToken", () => { expect(response.Data?.inputQuantity).toBe("500"); expect(new BigNumber(response.Data?.outputQuantity || "0").isPositive()).toBe(true); }); + + let galaPurchaseQtyDefaultSupply: BigNumber; + + test("Adjustable supply: Single transaction yields expected value for default 10 Million supply", async () => { + // Given + const multiplier = undefined; + + sale = new LaunchpadSale( + vaultAddress, + currencyInstance.instanceKeyObj(), + undefined, + users.testUser1.identityKey, + undefined, + multiplier + ); + + const { ctx, contract } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + const buyDto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("500")); + + buyDto.uniqueKey = randomUniqueKey(); + buyDto.sign(users.testUser1.privateKey); + + const expectedBuyResponse = plainToInstance(TradeResDto, { + inputQuantity: "0.00825575", + totalFees: "0", + totalTokenSold: new BigNumber("500").toString(), + outputQuantity: new BigNumber("500").toString(), + tokenName: "AUTOMATEDTESTCOIN", + tradeType: "Buy", + uniqueKey: buyDto.uniqueKey, + vaultAddress: "service|GALA$Unit$none$none$launchpad", + userAddress: "client|testUser1", + isFinalized: false, + functionName: "BuyExactToken" + }); + + const sellDto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("50")); + sellDto.uniqueKey = randomUniqueKey(); + const signedDto = sellDto.signed(users.testUser1.privateKey); + + // When + const buyRes = await contract.BuyExactToken(ctx, buyDto); + + const sellRes = await contract.SellExactToken(ctx, signedDto); + + // Then + expect(buyRes).toEqual(transactionSuccess(expectedBuyResponse)); + + expect(sellRes.Status).toBe(1); + expect(sellRes.Data?.inputQuantity).toBe("50"); + expect(sellRes.Data?.outputQuantity).toBe("0.00082579"); + + galaPurchaseQtyDefaultSupply = new BigNumber(sellRes.Data?.outputQuantity ?? 0); + }); + + test("Adjustable supply: Single transaction yields expected quantity for 100x scaled 1 Billion supply", async () => { + // Given + const multiplier = 100; + const inputQty = new BigNumber("50").times(multiplier); + + sale = new LaunchpadSale( + vaultAddress, + currencyInstance.instanceKeyObj(), + undefined, + users.testUser1.identityKey, + undefined, + multiplier + ); + + const { ctx, contract } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + const buyDto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("500").times(multiplier)); + + buyDto.uniqueKey = randomUniqueKey(); + buyDto.sign(users.testUser1.privateKey); + + const expectedBuyResponse = plainToInstance(TradeResDto, { + inputQuantity: "0.00825575", + totalFees: "0", + totalTokenSold: new BigNumber("500").times(multiplier).toString(), + outputQuantity: new BigNumber("500").times(multiplier).toString(), + tokenName: "AUTOMATEDTESTCOIN", + tradeType: "Buy", + uniqueKey: buyDto.uniqueKey, + vaultAddress: "service|GALA$Unit$none$none$launchpad", + userAddress: "client|testUser1", + isFinalized: false, + functionName: "BuyExactToken" + }); + + const sellDto = new ExactTokenQuantityDto(vaultAddress, inputQty); + sellDto.uniqueKey = randomUniqueKey(); + const signedDto = sellDto.signed(users.testUser1.privateKey); + + // When + const buyRes = await contract.BuyExactToken(ctx, buyDto); + + const sellRes = await contract.SellExactToken(ctx, signedDto); + + // Then + expect(buyRes).toEqual(transactionSuccess(expectedBuyResponse)); + + expect(sellRes.Status).toBe(1); + expect(sellRes.Data?.inputQuantity).toBe(inputQty.toString()); + expect(sellRes.Data?.outputQuantity).toBe("0.00082579"); + + const galaPurchaseQty100xSupply = new BigNumber(sellRes.Data?.outputQuantity ?? -1); + + // Compared to the previous test where the Launchpad has the default 10 Million supply, + // We expect the Meme token Qty to scale 100x and the Gala Qty to remain the same + expect(galaPurchaseQtyDefaultSupply).toEqual(galaPurchaseQty100xSupply); + }); }); diff --git a/src/chaincode/launchpad/sellWithNative.spec.ts b/src/chaincode/launchpad/sellWithNative.spec.ts index 5d0c7f0..e9fda17 100644 --- a/src/chaincode/launchpad/sellWithNative.spec.ts +++ b/src/chaincode/launchpad/sellWithNative.spec.ts @@ -26,7 +26,7 @@ import { currency, fixture, transactionError, transactionSuccess, users } from " import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; -import { LaunchpadSale, NativeTokenQuantityDto, TradeResDto } from "../../api/types"; +import { ExactTokenQuantityDto, LaunchpadSale, NativeTokenQuantityDto, TradeResDto } from "../../api/types"; import { LaunchpadContract } from "../LaunchpadContract"; import launchpadgala from "../test/launchpadgala"; @@ -286,4 +286,160 @@ describe("sellWithNative", () => { // todo: check writes map, verify vault balance }); + + let galaPurchaseQtyDefaultSupply: BigNumber; + let memeSaleQtyDefaultSupply: BigNumber; + + test("Adjustable supply: Single transaction yields expected value for default 10 Million supply", async () => { + // Given + const multiplier = undefined; + galaPurchaseQtyDefaultSupply = new BigNumber("0.00082579"); + memeSaleQtyDefaultSupply = new BigNumber("49.999949130655"); + + sale = new LaunchpadSale( + vaultAddress, + currencyInstance.instanceKeyObj(), + undefined, + users.testUser1.identityKey, + undefined, + multiplier + ); + + const { ctx, contract } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + const buyDto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("500")); + + buyDto.uniqueKey = randomUniqueKey(); + buyDto.sign(users.testUser1.privateKey); + + const expectedBuyResponse = plainToInstance(TradeResDto, { + inputQuantity: "0.00825575", + totalFees: "0", + totalTokenSold: new BigNumber("500").toString(), + outputQuantity: new BigNumber("500").toString(), + tokenName: "AUTOMATEDTESTCOIN", + tradeType: "Buy", + uniqueKey: buyDto.uniqueKey, + vaultAddress: "service|GALA$Unit$none$none$launchpad", + userAddress: "client|testUser1", + isFinalized: false, + functionName: "BuyExactToken" + }); + + const sellDto = new NativeTokenQuantityDto(vaultAddress, galaPurchaseQtyDefaultSupply); + sellDto.uniqueKey = randomUniqueKey(); + const signedDto = sellDto.signed(users.testUser1.privateKey); + + // When + const buyRes = await contract.BuyExactToken(ctx, buyDto); + + const sellRes = await contract.SellWithNative(ctx, signedDto); + + // Then + expect(buyRes).toEqual(transactionSuccess(expectedBuyResponse)); + + expect(sellRes).toEqual( + transactionSuccess( + expect.objectContaining({ + outputQuantity: galaPurchaseQtyDefaultSupply.toString(), + // extra precision in set constant above accounts for loss of precision + // when increased by the multiplier below. + // here, we round to the token decimal places to match the internal logic + inputQuantity: memeSaleQtyDefaultSupply.decimalPlaces(10).toString() + }) + ) + ); + galaPurchaseQtyDefaultSupply = new BigNumber(sellRes.Data?.outputQuantity ?? 0); + }); + + test("Adjustable supply: Single transaction yields expected quantity for 100x scaled 1 Billion supply", async () => { + // Given + const multiplier = 100; + + // Same Gala purchase amount should buy 100x meme token output + const inputQty = new BigNumber(galaPurchaseQtyDefaultSupply); + + sale = new LaunchpadSale( + vaultAddress, + currencyInstance.instanceKeyObj(), + undefined, + users.testUser1.identityKey, + undefined, + multiplier + ); + + const { ctx, contract } = fixture(LaunchpadContract) + .registeredUsers(users.testUser1) + .savedState( + currencyClass, + currencyInstance, + launchpadGalaClass, + launchpadGalaInstance, + sale, + salelaunchpadGalaBalance, + saleCurrencyBalance, + userlaunchpadGalaBalance, + userCurrencyBalance + ); + + const buyDto = new ExactTokenQuantityDto(vaultAddress, new BigNumber("500").times(multiplier)); + + buyDto.uniqueKey = randomUniqueKey(); + buyDto.sign(users.testUser1.privateKey); + + const expectedBuyResponse = plainToInstance(TradeResDto, { + inputQuantity: "0.00825575", + totalFees: "0", + totalTokenSold: new BigNumber("500").times(multiplier).toString(), + outputQuantity: new BigNumber("500").times(multiplier).toString(), + tokenName: "AUTOMATEDTESTCOIN", + tradeType: "Buy", + uniqueKey: buyDto.uniqueKey, + vaultAddress: "service|GALA$Unit$none$none$launchpad", + userAddress: "client|testUser1", + isFinalized: false, + functionName: "BuyExactToken" + }); + + const sellDto = new NativeTokenQuantityDto(vaultAddress, inputQty); + sellDto.uniqueKey = randomUniqueKey(); + const signedDto = sellDto.signed(users.testUser1.privateKey); + + // When + const buyRes = await contract.BuyExactToken(ctx, buyDto); + + const sellRes = await contract.SellWithNative(ctx, signedDto); + + // Then + expect(buyRes).toEqual(transactionSuccess(expectedBuyResponse)); + + expect(sellRes).toEqual( + transactionSuccess( + expect.objectContaining({ + inputQuantity: memeSaleQtyDefaultSupply.times(multiplier).toString(), + outputQuantity: galaPurchaseQtyDefaultSupply.toString() + }) + ) + ); + + const galaPurchaseQty100xSupply = new BigNumber(sellRes.Data?.outputQuantity ?? -1); + const memeSaleQty100xSupply = new BigNumber(sellRes.Data?.inputQuantity ?? -1); + + // Compared to the previous test where the Launchpad has the default 10 Million supply, + // We expect the Meme token Qty to scale 100x and the Gala Qty to remain the same + expect(galaPurchaseQtyDefaultSupply).toEqual(galaPurchaseQty100xSupply); + expect(memeSaleQtyDefaultSupply).toEqual(memeSaleQty100xSupply.dividedBy(multiplier)); + }); }); diff --git a/src/chaincode/utils/launchpadSaleUtils.ts b/src/chaincode/utils/launchpadSaleUtils.ts index 4a2dcd0..6a26281 100644 --- a/src/chaincode/utils/launchpadSaleUtils.ts +++ b/src/chaincode/utils/launchpadSaleUtils.ts @@ -54,9 +54,13 @@ export async function fetchAndValidateSale( return sale; } -export function getBondingConstants() { +export function getBondingConstants(multiplier?: number) { + const exponentFactor = + multiplier && multiplier > 0 + ? new Decimal("1166069000000").times(1 / multiplier) + : new Decimal("1166069000000"); return { - exponentFactor: new Decimal("1166069000000"), // exponent factor / b + exponentFactor: exponentFactor, // exponent factor / b euler: new Decimal("2.7182818284590452353602874713527"), // e decimals: new Decimal("1e+18") // scaling factor for decimals };