From 36603607faae8ed6d03d7ee3490216475ad10ffe Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 2 Sep 2026 08:19:09 +0200 Subject: [PATCH 1/2] Fix: Drop undeclared fields when reading chain objects Leftover properties from older class definitions are stripped on deserialize, so fetch responses can use constructors and strict nested validation again. Co-authored-by: Cursor --- chain-api/src/types/ChainObject.spec.ts | 19 ++++ chain-api/src/types/ChainObject.ts | 7 +- chain-api/src/types/RangedChainObject.spec.ts | 17 ++++ chain-api/src/types/RangedChainObject.ts | 12 ++- chain-api/src/types/TokenBalance.spec.ts | 23 +++++ .../src/types/TokenInstanceMetadata.spec.ts | 32 +++++- chain-api/src/types/TokenInstanceMetadata.ts | 8 ++ chain-api/src/types/token.ts | 52 +++++++--- chain-api/src/utils/index.ts | 1 + chain-api/src/utils/stripUnknownProperties.ts | 98 +++++++++++++++++++ .../fetchBalancesWithTokenMetadata.spec.ts | 32 ++++-- .../fetchBalancesWithTokenMetadata.ts | 17 +--- chaincode/src/token/fetchTokenClasses.ts | 8 +- .../fetchTokenInstanceMetadata.spec.ts | 30 ++++++ .../fetchTokenInstanceMetadata.ts | 7 +- 15 files changed, 308 insertions(+), 55 deletions(-) create mode 100644 chain-api/src/utils/stripUnknownProperties.ts diff --git a/chain-api/src/types/ChainObject.spec.ts b/chain-api/src/types/ChainObject.spec.ts index e224d1b3a3..c67d86b06b 100644 --- a/chain-api/src/types/ChainObject.spec.ts +++ b/chain-api/src/types/ChainObject.spec.ts @@ -37,6 +37,25 @@ class TestClass extends ChainObject { } } +it("should drop undeclared fields when deserializing a chain object", () => { + // Given + const stored = { + bigNum: "123", + category: "legendary", + quantityLocked: "5", + leftoverField: true + }; + + // When + const obj = ChainObject.deserialize(TestClass, stored); + + // Then + expect(obj).toBeInstanceOf(TestClass); + expect(obj.bigNum).toEqual(new BigNumber("123")); + expect(obj).not.toHaveProperty("quantityLocked"); + expect(obj).not.toHaveProperty("leftoverField"); +}); + it("should use custom serializers while constructing composite key", () => { // Given const bigNumStr = "730750818665451215712927172538123444058715062271"; // MAX_SAFE_INTEGER^3 diff --git a/chain-api/src/types/ChainObject.ts b/chain-api/src/types/ChainObject.ts index 32a6f53c7a..b83dc32845 100644 --- a/chain-api/src/types/ChainObject.ts +++ b/chain-api/src/types/ChainObject.ts @@ -22,7 +22,8 @@ import { ValidationFailedError, deserialize, getValidationErrorMessages, - serialize + serialize, + stripUnknownProperties } from "../utils"; import { ClassConstructor, Inferred } from "./dtos"; @@ -79,7 +80,9 @@ export abstract class ChainObject { constructor: ClassConstructor>, object: string | Record | Record[] ): T { - return deserialize(constructor, object); + const result = deserialize(constructor, object); + stripUnknownProperties(result as object); + return result; } public getCompositeKey(): string { diff --git a/chain-api/src/types/RangedChainObject.spec.ts b/chain-api/src/types/RangedChainObject.spec.ts index 4c947b3104..86bb064bfa 100644 --- a/chain-api/src/types/RangedChainObject.spec.ts +++ b/chain-api/src/types/RangedChainObject.spec.ts @@ -34,6 +34,23 @@ class TestRangedClass extends RangedChainObject { } } +it("should drop undeclared fields when deserializing a ranged chain object", () => { + // Given + const stored = { + isNft: true, + category: "legendary", + leftoverField: "drop-me" + }; + + // When + const obj = RangedChainObject.deserialize(TestRangedClass, stored); + + // Then + expect(obj).toBeInstanceOf(TestRangedClass); + expect(obj.isNft).toEqual(true); + expect(obj).not.toHaveProperty("leftoverField"); +}); + it("should use custom serializers while constructing ranged key", () => { // Given const obj = new TestRangedClass(true, "legendary"); diff --git a/chain-api/src/types/RangedChainObject.ts b/chain-api/src/types/RangedChainObject.ts index c39201bb43..3d26eac14c 100644 --- a/chain-api/src/types/RangedChainObject.ts +++ b/chain-api/src/types/RangedChainObject.ts @@ -16,7 +16,13 @@ import { instanceToPlain } from "class-transformer"; import { ValidationError, validate } from "class-validator"; import "reflect-metadata"; -import { ChainKeyMetadata, ValidationFailedError, deserialize, serialize } from "../utils"; +import { + ChainKeyMetadata, + ValidationFailedError, + deserialize, + serialize, + stripUnknownProperties +} from "../utils"; import { ChainObject, ObjectValidationFailedError } from "./ChainObject"; import { ClassConstructor, Inferred } from "./dtos"; @@ -51,7 +57,9 @@ export abstract class RangedChainObject { constructor: ClassConstructor>, object: string | Record | Record[] ): T { - return deserialize(constructor, object); + const result = deserialize(constructor, object); + stripUnknownProperties(result as object); + return result; } public getRangedKey(): string { diff --git a/chain-api/src/types/TokenBalance.spec.ts b/chain-api/src/types/TokenBalance.spec.ts index ee3d76a483..9808fff739 100644 --- a/chain-api/src/types/TokenBalance.spec.ts +++ b/chain-api/src/types/TokenBalance.spec.ts @@ -684,6 +684,29 @@ describe("legacy state compatibility", () => { expect(errors).toEqual([]); }); + it("should drop unknown legacy fields when deserializing a TokenBalance", () => { + // Given - stored balance JSON with fields removed from TokenBalance + const storedBalance = { + owner: "client|user1", + collection: "test-collection", + category: "test-category", + type: "test-type", + additionalKey: "test-additional-key", + quantity: "10", + quantityLocked: "5", + quantityInUse: "2" + }; + + // When + const balance = TokenBalance.deserialize(TokenBalance, storedBalance); + + // Then + expect(balance).toBeInstanceOf(TokenBalance); + expect(balance).not.toHaveProperty("quantityLocked"); + expect(balance).not.toHaveProperty("quantityInUse"); + expect(balance.getQuantityTotal()).toEqual(new BigNumber("10")); + }); + it("should build TokenBalanceWithMetadata from a balance with unknown legacy fields", async () => { // Given - stored balance JSON with fields removed before the SDK was open-sourced const storedBalance = { diff --git a/chain-api/src/types/TokenInstanceMetadata.spec.ts b/chain-api/src/types/TokenInstanceMetadata.spec.ts index f065747cf5..4eb37747ca 100644 --- a/chain-api/src/types/TokenInstanceMetadata.spec.ts +++ b/chain-api/src/types/TokenInstanceMetadata.spec.ts @@ -18,7 +18,8 @@ import { FetchTokenInstanceMetadataWithPaginationDto, MAX_METADATA_ATTRIBUTES, MAX_METADATA_CUSTOM_FIELDS, - SetTokenInstanceMetadataDto + SetTokenInstanceMetadataDto, + TokenInstanceMetadata } from "./TokenInstanceMetadata"; const tokenInstance = { @@ -151,6 +152,35 @@ describe("SetTokenInstanceMetadataDto array caps", () => { }); }); +describe("TokenInstanceMetadata deserialize", () => { + it("should keep customFields and drop leftover fields", () => { + // Given + const stored = { + collection: tokenInstance.collection, + category: tokenInstance.category, + type: tokenInstance.type, + additionalKey: tokenInstance.additionalKey, + instance: "1", + project: "TestProject", + name: "Test Elixir #1", + customFields: [{ key: "gameId", value: "elixir-001" }], + leftoverField: "should-be-dropped", + createdBy: "client|admin", + lastModifiedBy: "client|admin", + created: 1, + lastModified: 1 + }; + + // When + const metadata = TokenInstanceMetadata.deserialize(TokenInstanceMetadata, stored); + + // Then + expect(metadata).toBeInstanceOf(TokenInstanceMetadata); + expect(metadata).not.toHaveProperty("leftoverField"); + expect(metadata.customFields).toEqual([expect.objectContaining({ key: "gameId", value: "elixir-001" })]); + }); +}); + describe("FetchTokenInstanceMetadataWithPaginationDto instance", () => { async function paginationErrorsFor(instance: string): Promise { const dto = plainToInstance(FetchTokenInstanceMetadataWithPaginationDto, { diff --git a/chain-api/src/types/TokenInstanceMetadata.ts b/chain-api/src/types/TokenInstanceMetadata.ts index f0586802b1..4c63ae5002 100644 --- a/chain-api/src/types/TokenInstanceMetadata.ts +++ b/chain-api/src/types/TokenInstanceMetadata.ts @@ -380,6 +380,14 @@ export class FetchTokenInstanceMetadataWithPaginationDto extends ChainCallDTO { } export class FetchTokenInstanceMetadataResponse extends ChainCallDTO { + constructor(params?: { results: TokenInstanceMetadata[]; nextPageBookmark?: string }) { + super(); + if (params) { + this.results = params.results; + this.nextPageBookmark = params.nextPageBookmark; + } + } + @JSONSchema({ description: "List of token instance metadata documents." }) @ValidateNested({ each: true }) @Type(() => TokenInstanceMetadata) diff --git a/chain-api/src/types/token.ts b/chain-api/src/types/token.ts index 6430a60b1d..de34fdd861 100644 --- a/chain-api/src/types/token.ts +++ b/chain-api/src/types/token.ts @@ -34,13 +34,7 @@ import { } from "class-validator"; import { JSONSchema } from "class-validator-jsonschema"; -import { - BigNumberIsNotNegative, - BigNumberIsPositive, - BigNumberProperty, - IsUserRef, - ValidateNestedAllowUnknown -} from "../validators"; +import { BigNumberIsNotNegative, BigNumberIsPositive, BigNumberProperty, IsUserRef } from "../validators"; import { TokenBalance } from "./TokenBalance"; import { TokenBalanceTargets } from "./TokenBalanceTargets"; import { TokenClass, TokenClassKey } from "./TokenClass"; @@ -116,10 +110,16 @@ export class FetchTokenClassesWithPaginationDto extends ChainCallDTO { } export class FetchTokenClassesResponse extends ChainCallDTO { + constructor(params?: { results: TokenClass[]; nextPageBookmark?: string }) { + super(); + if (params) { + this.results = params.results; + this.nextPageBookmark = params.nextPageBookmark; + } + } + @JSONSchema({ description: "List of Token Classes." }) - // Token classes read of chain may carry legacy fields absent from the current - // class definition, so unknown properties must not be rejected. - @ValidateNestedAllowUnknown({ each: true }) + @ValidateNested({ each: true }) @Type(() => TokenClass) results: TokenClass[]; @@ -476,12 +476,18 @@ export class FetchBalancesWithPaginationDto extends ChainCallDTO { description: "Response DTO containing a TokenBalance and the balance's corresponding TokenClass." }) export class TokenBalanceWithMetadata extends ChainCallDTO { + constructor(params?: { balance: TokenBalance; token: TokenClass }) { + super(); + if (params) { + this.balance = params.balance; + this.token = params.token; + } + } + @JSONSchema({ description: "A TokenBalance read of chain." }) - // Balances read of chain may carry fields removed from TokenBalance long ago - // (pre-open-source legacy state), so unknown properties must not be rejected. - @ValidateNestedAllowUnknown() + @ValidateNested() @Type(() => TokenBalance) @IsObject() balance: TokenBalance; @@ -495,10 +501,16 @@ export class TokenBalanceWithMetadata extends ChainCallDTO { } export class FetchBalancesWithPaginationResponse extends ChainCallDTO { + constructor(params?: { results: TokenBalance[]; nextPageBookmark?: string }) { + super(); + if (params) { + this.results = params.results; + this.nextPageBookmark = params.nextPageBookmark; + } + } + @JSONSchema({ description: "List of balances with token metadata." }) - // Balances read of chain may carry legacy fields absent from the current - // class definition, so unknown properties must not be rejected. - @ValidateNestedAllowUnknown({ each: true }) + @ValidateNested({ each: true }) @Type(() => TokenBalance) results: TokenBalance[]; @@ -509,6 +521,14 @@ export class FetchBalancesWithPaginationResponse extends ChainCallDTO { } export class FetchBalancesWithTokenMetadataResponse extends ChainCallDTO { + constructor(params?: { results: TokenBalanceWithMetadata[]; nextPageBookmark?: string }) { + super(); + if (params) { + this.results = params.results; + this.nextPageBookmark = params.nextPageBookmark; + } + } + @JSONSchema({ description: "List of balances with token metadata." }) @ValidateNested({ each: true }) @Type(() => TokenBalanceWithMetadata) diff --git a/chain-api/src/utils/index.ts b/chain-api/src/utils/index.ts index 798c767ec0..f6be507526 100644 --- a/chain-api/src/utils/index.ts +++ b/chain-api/src/utils/index.ts @@ -23,6 +23,7 @@ export * from "./error"; export * from "../ethers/type-utils"; export * from "./randomUniqueKey"; +export { stripUnknownProperties } from "./stripUnknownProperties"; export { deserialize, serialize, diff --git a/chain-api/src/utils/stripUnknownProperties.ts b/chain-api/src/utils/stripUnknownProperties.ts new file mode 100644 index 0000000000..e612c6ef80 --- /dev/null +++ b/chain-api/src/utils/stripUnknownProperties.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { getMetadataStorage } from "class-validator"; + +import { ChainKeyMetadata } from "./chain-decorators"; + +type ClassLike = { new (...args: unknown[]): unknown }; + +const knownPropertyCache = new WeakMap>(); + +function getKnownPropertyNames(constructor: ClassLike): Set { + const cached = knownPropertyCache.get(constructor); + if (cached) { + return cached; + } + + const names = new Set(); + + const metadatas = getMetadataStorage().getTargetValidationMetadatas(constructor, "", false, false); + for (const metadata of metadatas) { + names.add(metadata.propertyName); + } + + let proto = constructor.prototype; + while (proto && proto !== Object.prototype) { + const fields: ChainKeyMetadata[] = Reflect.getOwnMetadata("galachain:chainkey", proto) || []; + for (const field of fields) { + names.add(String(field.key)); + } + proto = Object.getPrototypeOf(proto); + } + + knownPropertyCache.set(constructor, names); + return names; +} + +function shouldRecurse(value: unknown): value is object { + return ( + value !== null && + typeof value === "object" && + !BigNumber.isBigNumber(value) && + !(value instanceof Date) && + !Buffer.isBuffer(value) + ); +} + +/** + * Removes properties that are not declared on the class (no validator and no @ChainKey). + * Used when reading ChainObject / RangedChainObject values that may still carry fields + * removed from the class definition. + */ +export function stripUnknownProperties(instance: object, visited = new WeakSet()): void { + if (visited.has(instance)) { + return; + } + visited.add(instance); + + if (Array.isArray(instance)) { + for (const item of instance) { + if (shouldRecurse(item)) { + stripUnknownProperties(item, visited); + } + } + return; + } + + const known = getKnownPropertyNames(instance.constructor as ClassLike); + for (const key of Object.keys(instance)) { + if (!known.has(key)) { + delete instance[key]; + continue; + } + + const value = instance[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (shouldRecurse(item) && item.constructor !== Object) { + stripUnknownProperties(item, visited); + } + } + } else if (shouldRecurse(value) && value.constructor !== Object) { + stripUnknownProperties(value, visited); + } + } +} diff --git a/chaincode/src/balances/fetchBalancesWithTokenMetadata.spec.ts b/chaincode/src/balances/fetchBalancesWithTokenMetadata.spec.ts index 130fdaa076..c9d158e3fb 100644 --- a/chaincode/src/balances/fetchBalancesWithTokenMetadata.spec.ts +++ b/chaincode/src/balances/fetchBalancesWithTokenMetadata.spec.ts @@ -24,6 +24,7 @@ import { currency, fixture, nft } from "@gala-chain/test"; import { plainToInstance } from "class-transformer"; import GalaChainTokenContract from "../__test__/GalaChainTokenContract"; +import { getObjectByKey } from "../utils"; it("should Fetch Token Balances with Token Class Metadata", async () => { // Given @@ -69,28 +70,43 @@ it("should fetch balances with legacy properties in saved state", async () => { // Given - balance state with fields removed from TokenBalance over the years, // including pre-open-source ones the SDK has no declaration for const currencyClass = currency.tokenClass(); + const currentBalance = currency.tokenBalance(); const legacyBalance = plainToInstance(TokenBalance, { - ...currency.tokenBalance().toPlainObject(), + ...currentBalance.toPlainObject(), inUseHolds: [], quantityLocked: "5", quantityInUse: "2" }); - const { ctx, contract } = fixture(GalaChainTokenContract).savedState(currencyClass, legacyBalance); + const { ctx, contract } = fixture(GalaChainTokenContract) + .savedState(currencyClass) + .savedKVState({ + key: currentBalance.getCompositeKey(), + value: JSON.stringify({ + ...currentBalance.toPlainObject(), + inUseHolds: [], + quantityLocked: "5", + quantityInUse: "2" + }) + }); const dto: FetchBalancesDto = await createValidDTO(FetchBalancesDto, { owner: legacyBalance.owner }); - const expectedResponse = await createValidDTO(FetchBalancesWithTokenMetadataResponse, { - results: [plainToInstance(TokenBalanceWithMetadata, { balance: legacyBalance, token: currencyClass })], - nextPageBookmark: "" - }); - // When + const fetched = await getObjectByKey(ctx, TokenBalance, currentBalance.getCompositeKey()); const response = await contract.FetchBalancesWithTokenMetadata(ctx, dto).catch((e) => e); - // Then + // Then - undeclared fields are dropped on read + expect(fetched).not.toHaveProperty("quantityLocked"); + expect(fetched).not.toHaveProperty("quantityInUse"); + expect(fetched.getQuantityTotal()).toEqual(currentBalance.getQuantityTotal()); + + const expectedResponse = await createValidDTO(FetchBalancesWithTokenMetadataResponse, { + results: [plainToInstance(TokenBalanceWithMetadata, { balance: fetched, token: currencyClass })], + nextPageBookmark: "" + }); expect(response).toEqual(GalaChainResponse.Success(expectedResponse)); }); diff --git a/chaincode/src/balances/fetchBalancesWithTokenMetadata.ts b/chaincode/src/balances/fetchBalancesWithTokenMetadata.ts index e2bc44105b..edd5b992db 100644 --- a/chaincode/src/balances/fetchBalancesWithTokenMetadata.ts +++ b/chaincode/src/balances/fetchBalancesWithTokenMetadata.ts @@ -22,7 +22,6 @@ import { TokenClass, UserAlias } from "@gala-chain/api"; -import { plainToInstance } from "class-transformer"; import { GalaChainContext } from "../types"; import { getObjectByKey, getObjectsByPartialCompositeKeyWithPagination, takeUntilUndefined } from "../utils"; @@ -93,21 +92,11 @@ export async function fetchBalancesWithTokenMetadata( const compositeKey = ChainObject.getCompositeKeyFromParts(TokenClass.INDEX_KEY, keyList); const tokenClass: TokenClass = await getObjectByKey(ctx, TokenClass, compositeKey); - // Chain entries are returned as-is, without response DTO validation. - // Balances read of chain may carry legacy fields absent from the current - // TokenBalance class definition, and validating them here would reject them. - const balanceWithTokenMetadata = plainToInstance(TokenBalanceWithMetadata, { - balance: balance, - token: tokenClass - }); - - results.push(balanceWithTokenMetadata); + results.push(new TokenBalanceWithMetadata({ balance, token: tokenClass })); } - const response = plainToInstance(FetchBalancesWithTokenMetadataResponse, { + return new FetchBalancesWithTokenMetadataResponse({ nextPageBookmark: balancesLookup.metadata.bookmark, - results: results + results }); - - return response; } diff --git a/chaincode/src/token/fetchTokenClasses.ts b/chaincode/src/token/fetchTokenClasses.ts index 5dca7cbc6c..041c9c1ed4 100644 --- a/chaincode/src/token/fetchTokenClasses.ts +++ b/chaincode/src/token/fetchTokenClasses.ts @@ -19,7 +19,6 @@ import { TokenClassKey, TokenInstanceKey } from "@gala-chain/api"; -import { plainToInstance } from "class-transformer"; import { GalaChainContext } from "../types"; import { getObjectByKey, getObjectsByPartialCompositeKeyWithPagination, takeUntilUndefined } from "../utils"; @@ -66,13 +65,8 @@ export async function fetchTokenClassesWithPagination( dto.limit ?? FetchTokenClassesWithPaginationDto.DEFAULT_LIMIT ); - // Chain entries are returned as-is, without response DTO validation. - // Token classes read of chain may carry legacy fields absent from the current - // class definition, and validating them here would reject them. - const response = plainToInstance(FetchTokenClassesResponse, { + return new FetchTokenClassesResponse({ results: getObjectsResponse.results, nextPageBookmark: getObjectsResponse.metadata.bookmark }); - - return response; } diff --git a/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.spec.ts b/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.spec.ts index e32b4c3ef6..7bc5befa88 100644 --- a/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.spec.ts +++ b/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.spec.ts @@ -54,6 +54,36 @@ it("should fetch metadata documents of all projects for an instance", async () = expect(getWrites()).toEqual({}); }); +it("should keep customFields when fetching metadata written with extra properties", async () => { + // Given - customFields is the additional-fields option; leftover keys are not + const savedMetadata = nft.tokenInstance1Metadata(); + expect(savedMetadata.customFields).toEqual([ + expect.objectContaining({ key: "gameId", value: "elixir-001" }) + ]); + + const { ctx, contract } = fixture(GalaChainTokenContract).savedKVState({ + key: savedMetadata.getCompositeKey(), + value: JSON.stringify({ + ...savedMetadata.toPlainObject(), + leftoverField: "should-be-dropped" + }) + }); + + const dto = await createValidDTO(FetchTokenInstanceMetadataDto, { + tokenInstance: nft.tokenInstance1Key(), + project: savedMetadata.project + }); + + // When + const response = await contract.FetchTokenInstanceMetadata(ctx, dto); + + // Then + const fetched = (response.Data as TokenInstanceMetadata[])[0]; + expect(fetched).not.toHaveProperty("leftoverField"); + expect(fetched.customFields).toEqual(savedMetadata.customFields); + expect(fetched.name).toEqual(savedMetadata.name); +}); + it("should fetch metadata document of a single project", async () => { // Given const savedMetadata = nft.tokenInstance1Metadata(); diff --git a/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.ts b/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.ts index ef1b0a7799..c792505d78 100644 --- a/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.ts +++ b/chaincode/src/tokenInstanceMetadata/fetchTokenInstanceMetadata.ts @@ -17,8 +17,7 @@ import { FetchTokenInstanceMetadataWithPaginationDto, TokenInstance, TokenInstanceKey, - TokenInstanceMetadata, - createValidDTO + TokenInstanceMetadata } from "@gala-chain/api"; import { GalaChainContext } from "../types"; @@ -106,10 +105,8 @@ export async function fetchTokenInstanceMetadataWithPagination( params.limit ?? FetchTokenInstanceMetadataWithPaginationDto.DEFAULT_LIMIT ); - const response = await createValidDTO(FetchTokenInstanceMetadataResponse, { + return new FetchTokenInstanceMetadataResponse({ results: getObjectsResponse.results, nextPageBookmark: getObjectsResponse.metadata.bookmark }); - - return response; } From e242ff0b77201223ed5169f60ca658a71d6d8362 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Wed, 2 Sep 2026 11:25:55 +0200 Subject: [PATCH 2/2] Fix: Keep constructor-initialized fields when reading chain objects Undeclared leftover properties are still dropped, but fields assigned in the constructor are part of the current class even without a validator. Co-authored-by: Cursor --- chain-api/src/types/ChainObject.spec.ts | 33 +++++++++++++++++++ chain-api/src/utils/stripUnknownProperties.ts | 16 ++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/chain-api/src/types/ChainObject.spec.ts b/chain-api/src/types/ChainObject.spec.ts index c67d86b06b..355f6571d4 100644 --- a/chain-api/src/types/ChainObject.spec.ts +++ b/chain-api/src/types/ChainObject.spec.ts @@ -14,6 +14,7 @@ */ import { BigNumber } from "bignumber.js"; import { Transform } from "class-transformer"; +import { IsString } from "class-validator"; import { ChainKey } from "../utils"; import { BigNumberProperty } from "../validators"; @@ -37,6 +38,21 @@ class TestClass extends ChainObject { } } +class TestClassWithConstructorField extends ChainObject { + static INDEX_KEY = "test-ctor"; + + @ChainKey({ position: 0 }) + @IsString() + owner: string; + + plantedAt: number; + + constructor() { + super(); + this.plantedAt = 0; + } +} + it("should drop undeclared fields when deserializing a chain object", () => { // Given const stored = { @@ -56,6 +72,23 @@ it("should drop undeclared fields when deserializing a chain object", () => { expect(obj).not.toHaveProperty("leftoverField"); }); +it("should keep constructor-initialized fields that have no validator", () => { + // Given + const stored = { + owner: "client|user1", + plantedAt: 1_700_000_000_000, + leftoverField: true + }; + + // When + const obj = ChainObject.deserialize(TestClassWithConstructorField, stored); + + // Then + expect(obj.plantedAt).toEqual(1_700_000_000_000); + expect(obj.owner).toEqual("client|user1"); + expect(obj).not.toHaveProperty("leftoverField"); +}); + it("should use custom serializers while constructing composite key", () => { // Given const bigNumStr = "730750818665451215712927172538123444058715062271"; // MAX_SAFE_INTEGER^3 diff --git a/chain-api/src/utils/stripUnknownProperties.ts b/chain-api/src/utils/stripUnknownProperties.ts index e612c6ef80..7f38568bc1 100644 --- a/chain-api/src/utils/stripUnknownProperties.ts +++ b/chain-api/src/utils/stripUnknownProperties.ts @@ -43,6 +43,19 @@ function getKnownPropertyNames(constructor: ClassLike): Set { proto = Object.getPrototypeOf(proto); } + // Fields assigned in the constructor are part of the current class even when + // they have no validator (e.g. AppleTree.plantedAt). + try { + const blank = new constructor(); + if (blank && typeof blank === "object") { + for (const key of Object.keys(blank)) { + names.add(key); + } + } + } catch { + // constructor requires arguments + } + knownPropertyCache.set(constructor, names); return names; } @@ -58,7 +71,8 @@ function shouldRecurse(value: unknown): value is object { } /** - * Removes properties that are not declared on the class (no validator and no @ChainKey). + * Removes properties that are not declared on the class (no validator, no @ChainKey, + * and not assigned in the constructor). * Used when reading ChainObject / RangedChainObject values that may still carry fields * removed from the class definition. */