From 47215ed847caa0828d2c3505b1dc3e9bd11ef1eb Mon Sep 17 00:00:00 2001 From: Leonn Leite Date: Wed, 14 Jan 2026 12:53:00 -0300 Subject: [PATCH] feat: add validation for future sale start time and include timeUntilLaunch in sale details --- src/api/types/LaunchpadSale.ts | 5 +++ src/chaincode/launchpad/createSale.spec.ts | 37 ++++++++++++++++++- src/chaincode/launchpad/createSale.ts | 6 ++- .../launchpad/fetchSaleDetails.spec.ts | 19 ++++++++++ src/chaincode/launchpad/fetchSaleDetails.ts | 4 ++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/api/types/LaunchpadSale.ts b/src/api/types/LaunchpadSale.ts index 4597cac..3b9351f 100644 --- a/src/api/types/LaunchpadSale.ts +++ b/src/api/types/LaunchpadSale.ts @@ -63,6 +63,11 @@ export class LaunchpadSale extends ChainObject { @IsInt() public saleStartTime?: number; + @IsOptional() + @IsInt() + @Min(0) + public timeUntilLaunch?: number; + @IsNotEmpty() @ValidateNested() @Type(() => TokenInstanceKey) diff --git a/src/chaincode/launchpad/createSale.spec.ts b/src/chaincode/launchpad/createSale.spec.ts index 960f443..3728637 100644 --- a/src/chaincode/launchpad/createSale.spec.ts +++ b/src/chaincode/launchpad/createSale.spec.ts @@ -12,7 +12,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TokenBalance, TokenClass, TokenInstance, randomUniqueKey } from "@gala-chain/api"; +import { + GalaChainResponse, + TokenBalance, + TokenClass, + TokenInstance, + ValidationFailedError, + randomUniqueKey +} from "@gala-chain/api"; import { fixture, users } from "@gala-chain/test"; import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; @@ -170,4 +177,32 @@ describe("createSale", () => { expect(response.Status).toBe(1); expect(response.Data?.image).toBe("https://cdn.example.com/token-logo.png"); }); + + it("should reject sale start time in the past", async () => { + // Given + const { ctx, contract } = fixture(LaunchpadContract).registeredUsers(users.testUser1); + + const createSaleDto = new CreateTokenSaleDTO( + "Past Start Token", + "PAST", + "A token with past sale start time", + "https://example.com/past.png", + new BigNumber(0), + "PastCollection", + "PastCategory", + undefined, + ctx.txUnixTime - 1 + ); + createSaleDto.uniqueKey = randomUniqueKey(); + + const signedDto = createSaleDto.signed(users.testUser1.privateKey); + + // When + const response = await contract.CreateSale(ctx, signedDto); + + // Then + expect(response).toEqual( + GalaChainResponse.Error(new ValidationFailedError("Sale start time must be in the future.")) + ); + }); }); diff --git a/src/chaincode/launchpad/createSale.ts b/src/chaincode/launchpad/createSale.ts index e892e5a..62024f2 100644 --- a/src/chaincode/launchpad/createSale.ts +++ b/src/chaincode/launchpad/createSale.ts @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ConflictError, TokenInstanceKey, asValidUserAlias } from "@gala-chain/api"; +import { ConflictError, TokenInstanceKey, ValidationFailedError, asValidUserAlias } from "@gala-chain/api"; import { GalaChainContext, createTokenClass, @@ -63,6 +63,10 @@ export async function createSale( launchpadDetails.tokenSymbol = launchpadDetails.tokenSymbol.toUpperCase(); + if (launchpadDetails.saleStartTime !== undefined && launchpadDetails.saleStartTime < ctx.txUnixTime) { + throw new ValidationFailedError("Sale start time must be in the future."); + } + // Define the token class key const tokenInstanceKey = new TokenInstanceKey(); tokenInstanceKey.collection = `${launchpadDetails.tokenCollection}`; diff --git a/src/chaincode/launchpad/fetchSaleDetails.spec.ts b/src/chaincode/launchpad/fetchSaleDetails.spec.ts index 63db239..473d347 100644 --- a/src/chaincode/launchpad/fetchSaleDetails.spec.ts +++ b/src/chaincode/launchpad/fetchSaleDetails.spec.ts @@ -63,6 +63,25 @@ describe("fetchSaleDetails", () => { expect(response.Data?.sellingToken).toEqual(launchpadGalaInstance.instanceKeyObj()); }); + it("should include timeUntilLaunch when saleStartTime is set", async () => { + // Given + sale.saleStartTime = Math.floor(Date.now() / 1000) + 60; + const { ctx, contract } = fixture(LaunchpadContract).registeredUsers(users.testUser1).savedState(sale); + + const fetchSaleDto = new FetchSaleDto(vaultAddress); + fetchSaleDto.uniqueKey = randomUniqueKey(); + + const signedDto = fetchSaleDto.signed(users.testUser1.privateKey); + + // When + const response = await contract.FetchSaleDetails(ctx, signedDto); + + // Then + const expected = Math.max(0, (sale.saleStartTime - ctx.txUnixTime) * 1000); + expect(response.Status).toBe(1); + expect(response.Data?.timeUntilLaunch).toBe(expected); + }); + it("should handle sale with existing trades", async () => { // Given sale.buyToken(new BigNumber("100"), new BigNumber("0.01")); diff --git a/src/chaincode/launchpad/fetchSaleDetails.ts b/src/chaincode/launchpad/fetchSaleDetails.ts index dc3a398..a1e0765 100644 --- a/src/chaincode/launchpad/fetchSaleDetails.ts +++ b/src/chaincode/launchpad/fetchSaleDetails.ts @@ -44,5 +44,9 @@ export async function fetchSaleDetails( throw new NotFoundError("Sale record not found."); } + const saleStartTime = sale.saleStartTime ?? 0; + const remainingSeconds = saleStartTime > 0 ? saleStartTime - ctx.txUnixTime : 0; + sale.timeUntilLaunch = Math.max(0, remainingSeconds) * 1000; + return sale; }