From aa223b5eeed68224187a1b2f76d64a941dbada00 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 29 Oct 2026 18:22:31 +0100 Subject: [PATCH 1/7] Fix: Allow $ in string keys and tighten key and query validation String keys accept $ again, so existing token classes with $ in a key field keep working. A NUL-wrapped key part is accepted only when it is a well-formed nested composite key. A burn query with created must include every preceding key field. Oracle authorities must be user aliases. Co-Authored-By: Claude Opus 5.5 --- chain-api/src/types/ChainObject.spec.ts | 32 ++++++++++- chain-api/src/types/ChainObject.ts | 21 +++++-- chain-api/src/types/OracleDefinition.ts | 4 +- chain-api/src/types/RangedChainObject.spec.ts | 5 +- chain-api/src/types/TokenClass.spec.ts | 15 +++++ chain-api/src/types/TokenInstance.spec.ts | 16 ++++++ chain-api/src/types/burn.spec.ts | 56 +++++++++++++++++++ chain-api/src/types/burn.ts | 20 +++++-- chain-api/src/types/oracle.spec.ts | 28 ++++++++++ chain-api/src/types/oracle.ts | 5 +- 10 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 chain-api/src/types/burn.spec.ts diff --git a/chain-api/src/types/ChainObject.spec.ts b/chain-api/src/types/ChainObject.spec.ts index d98a2a5786..306f3c7e90 100644 --- a/chain-api/src/types/ChainObject.spec.ts +++ b/chain-api/src/types/ChainObject.spec.ts @@ -100,10 +100,36 @@ it("rejects a NUL character inside a composite key part", () => { expect(() => ChainObject.getCompositeKeyFromParts("outer", [nested])).not.toThrow(); }); -it("rejects the string key separator inside a part", () => { +it("rejects composite key parts that only look like nested composite keys", () => { + // Given + const nul = "\u0000"; + const first = [`${nul}a${nul}`, `${nul}b${nul}${nul}${nul}c${nul}`]; + const second = [`${nul}a${nul}${nul}${nul}b${nul}`, `${nul}c${nul}`]; + + // When & Then + expect(() => ChainObject.getCompositeKeyFromParts("idx", first)).toThrow(/NUL/); + expect(() => ChainObject.getCompositeKeyFromParts("idx", second)).toThrow(/NUL/); + expect(() => ChainObject.getCompositeKeyFromParts("idx", [nul])).toThrow(/NUL/); + expect(() => ChainObject.getCompositeKeyFromParts("idx", [`${nul}${nul}`])).toThrow(/NUL/); + expect(() => ChainObject.joinKeyParts(first)).toThrow(/NUL/); +}); + +it("accepts a well-formed nested composite key as a part", () => { + // Given + const nested = ChainObject.getCompositeKeyFromParts("inner", ["1700000000000", "tx1"]); + + // When + const key = ChainObject.getCompositeKeyFromParts("outer", [nested, "client|user"]); + + // Then + expect(key).toEqual(`\u0000outer\u0000${nested}\u0000client|user\u0000`); + expect(ChainObject.joinKeyParts([nested, "x"])).toEqual(`${nested}\u0000x`); +}); + +it("allows $ but rejects NUL inside a string key part", () => { expect(ChainObject.getStringKeyFromParts(["a", "b_c"])).toEqual("a$b_c"); - expect(() => ChainObject.getStringKeyFromParts(["a$b", "c"])).toThrow(/separator/); - expect(() => ChainObject.getStringKeyFromParts(["a\u0000b"])).toThrow(/separator/); + expect(ChainObject.getStringKeyFromParts(["a$b", "c"])).toEqual("a$b$c"); + expect(() => ChainObject.getStringKeyFromParts(["a\u0000b"])).toThrow(/NUL/); }); it("rejects a NUL character in a joined query prefix", () => { diff --git a/chain-api/src/types/ChainObject.ts b/chain-api/src/types/ChainObject.ts index fb8b4b7128..bf2052fd8e 100644 --- a/chain-api/src/types/ChainObject.ts +++ b/chain-api/src/types/ChainObject.ts @@ -150,19 +150,30 @@ export abstract class ChainObject { } // A complete composite key is sometimes stored as one component of another key. - // It starts and ends with the separator. Any other embedded separator is ambiguous. - const isNestedCompositeKey = text.startsWith(nul) && text.endsWith(nul) && text.length > 1; - if (!isNestedCompositeKey) { + // It must be well formed: the separator, then non-empty segments each followed by the separator. + // Any other embedded separator is ambiguous. + if (!ChainObject.isNestedCompositeKey(text)) { throw new InvalidCompositeKeyError(`${label} contains a NUL character`); } return text; } + private static isNestedCompositeKey(text: string): boolean { + const nul = ChainObject.MIN_UNICODE_RUNE_VALUE; + if (text.length < 3 || !text.startsWith(nul) || !text.endsWith(nul)) { + return false; + } + const segments = text.slice(1, -1).split(nul); + return segments.every((segment) => segment.length > 0); + } + public static getStringKeyFromParts(parts: string[]): string { + // The "$" separator is not rejected here: existing token classes already use it in key fields, + // and their string keys must stay readable in the existing format. const texts = parts.map((part, index) => { const text = part === null || part === undefined ? "" : String(part); - if (text.includes(ChainObject.ID_SPLIT_CHAR) || text.includes(ChainObject.MIN_UNICODE_RUNE_VALUE)) { - throw new InvalidCompositeKeyError(`string key part at index ${index} contains the separator`); + if (text.includes(ChainObject.MIN_UNICODE_RUNE_VALUE)) { + throw new InvalidCompositeKeyError(`string key part at index ${index} contains a NUL character`); } return text; }); diff --git a/chain-api/src/types/OracleDefinition.ts b/chain-api/src/types/OracleDefinition.ts index 6ec3a3e15f..f2e8b9384b 100644 --- a/chain-api/src/types/OracleDefinition.ts +++ b/chain-api/src/types/OracleDefinition.ts @@ -15,6 +15,7 @@ import { ArrayNotEmpty, IsNotEmpty, IsString } from "class-validator"; import { ChainKey } from "../utils"; +import { IsUserAlias } from "../validators"; import { ChainObject } from "./ChainObject"; export class OracleDefinition extends ChainObject { @@ -26,7 +27,6 @@ export class OracleDefinition extends ChainObject { public name: string; @ArrayNotEmpty() - @IsString({ each: true }) - @IsNotEmpty({ each: true }) + @IsUserAlias({ each: true }) public authorities: string[]; } diff --git a/chain-api/src/types/RangedChainObject.spec.ts b/chain-api/src/types/RangedChainObject.spec.ts index 261ecf2e3a..f4a2a2c50e 100644 --- a/chain-api/src/types/RangedChainObject.spec.ts +++ b/chain-api/src/types/RangedChainObject.spec.ts @@ -55,7 +55,10 @@ it("rejects a NUL character inside a ranged key part", () => { expect(() => RangedChainObject.getRangedKeyFromParts("idx", ["a\u0000b", "c"])).toThrow(/NUL/); expect(() => RangedChainObject.getRangedKeyFromParts("id\u0000x", ["a"])).toThrow(/NUL/); expect(RangedChainObject.getStringKeyFromParts(["a", "b_c"])).toEqual("a$b_c"); - expect(() => RangedChainObject.getStringKeyFromParts(["a$b", "c"])).toThrow(/separator/); + expect(RangedChainObject.getStringKeyFromParts(["a$b", "c"])).toEqual("a$b$c"); + expect(() => RangedChainObject.getRangedKeyFromParts("idx", ["\u0000a\u0000\u0000\u0000b\u0000"])).toThrow( + /NUL/ + ); }); it("should use custom serializers while constructing ranged key", () => { diff --git a/chain-api/src/types/TokenClass.spec.ts b/chain-api/src/types/TokenClass.spec.ts index e9b79337ac..8f3b5eb6d8 100644 --- a/chain-api/src/types/TokenClass.spec.ts +++ b/chain-api/src/types/TokenClass.spec.ts @@ -176,3 +176,18 @@ it("should encode and decode successfully with utf 0 in keys", async () => { expect(encode).toThrow("Invalid part with UTF-0 at index 2 passed"); expect(decode).toThrow("Expected 4 parts, got 5 parts"); }); + +it("should build a string key when key fields contain $", () => { + // Given + const classKey = new TokenClassKey(); + classKey.collection = "$Test"; + classKey.category = "Cl$ass"; + classKey.type = "Key"; + classKey.additionalKey = "none"; + + // When + const stringKey = classKey.toStringKey(); + + // Then + expect(stringKey).toEqual("$Test$Cl$ass$Key$none"); +}); diff --git a/chain-api/src/types/TokenInstance.spec.ts b/chain-api/src/types/TokenInstance.spec.ts index 9ac0a5f5d4..02e83e29e2 100644 --- a/chain-api/src/types/TokenInstance.spec.ts +++ b/chain-api/src/types/TokenInstance.spec.ts @@ -95,3 +95,19 @@ it("builds an instance key from the whole integer", () => { ChainObject.getCompositeKeyFromParts(TokenInstance.INDEX_KEY, ["collection", "12"]) ); }); + +it("should build a string key when key fields contain $", () => { + // Given + const instance = new TokenInstanceKey(); + instance.collection = "$Test"; + instance.category = "Ve$ry"; + instance.type = "Large"; + instance.additionalKey = "none"; + instance.instance = new BigNumber(0); + + // When + const stringKey = instance.toStringKey(); + + // Then + expect(stringKey).toEqual("$Test$Ve$ry$Large$none$0"); +}); diff --git a/chain-api/src/types/burn.spec.ts b/chain-api/src/types/burn.spec.ts new file mode 100644 index 0000000000..258e89f31c --- /dev/null +++ b/chain-api/src/types/burn.spec.ts @@ -0,0 +1,56 @@ +/* + * 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 { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; + +import { FetchBurnsDto } from "./burn"; + +describe("FetchBurnsDto", () => { + it("rejects created when a preceding key field is missing", async () => { + // Given + const dto = plainToInstance(FetchBurnsDto, { + burnedBy: "client|user1", + collection: "Test", + created: 1700000000000 + }); + + // When + const errors = await validate(dto); + + // Then + expect(errors.map((e) => e.property)).toEqual( + expect.arrayContaining(["category", "type", "additionalKey", "instance"]) + ); + }); + + it("accepts created when all preceding key fields are present", async () => { + // Given + const dto = plainToInstance(FetchBurnsDto, { + burnedBy: "client|user1", + collection: "Test", + category: "Item", + type: "Sword", + additionalKey: "none", + instance: "1", + created: 1700000000000 + }); + + // When + const errors = await validate(dto); + + // Then + expect(errors).toHaveLength(0); + }); +}); diff --git a/chain-api/src/types/burn.ts b/chain-api/src/types/burn.ts index 88450a61cc..6b488501b3 100644 --- a/chain-api/src/types/burn.ts +++ b/chain-api/src/types/burn.ts @@ -56,7 +56,8 @@ export class FetchBurnsDto extends ChainCallDTO { burn.category !== undefined || burn.type !== undefined || burn.additionalKey !== undefined || - burn.instance !== undefined + burn.instance !== undefined || + burn.created !== undefined ) @IsString() @IsNotEmpty() @@ -70,7 +71,8 @@ export class FetchBurnsDto extends ChainCallDTO { burn.category !== undefined || burn.type !== undefined || burn.additionalKey !== undefined || - burn.instance !== undefined + burn.instance !== undefined || + burn.created !== undefined ) @IsString() @IsNotEmpty() @@ -81,7 +83,10 @@ export class FetchBurnsDto extends ChainCallDTO { }) @ValidateIf( (burn: FetchBurnsDto) => - burn.type !== undefined || burn.additionalKey !== undefined || burn.instance !== undefined + burn.type !== undefined || + burn.additionalKey !== undefined || + burn.instance !== undefined || + burn.created !== undefined ) @IsString() @IsNotEmpty() @@ -90,15 +95,18 @@ export class FetchBurnsDto extends ChainCallDTO { @JSONSchema({ description: "Token additionalKey. Optional, but required if instance is provided." }) - @ValidateIf((burn: FetchBurnsDto) => burn.additionalKey !== undefined || burn.instance !== undefined) + @ValidateIf( + (burn: FetchBurnsDto) => + burn.additionalKey !== undefined || burn.instance !== undefined || burn.created !== undefined + ) @IsString() @IsNotEmpty() additionalKey?: string; @JSONSchema({ - description: "Token instance. Optional." + description: "Token instance. Optional, but required if created is provided." }) - @ValidateIf((burn: FetchBurnsDto) => burn.instance !== undefined) + @ValidateIf((burn: FetchBurnsDto) => burn.instance !== undefined || burn.created !== undefined) @IsString() @IsNotEmpty() instance?: string; diff --git a/chain-api/src/types/oracle.spec.ts b/chain-api/src/types/oracle.spec.ts index 3fd9781abb..bc66d996d3 100644 --- a/chain-api/src/types/oracle.spec.ts +++ b/chain-api/src/types/oracle.spec.ts @@ -16,6 +16,7 @@ import BigNumber from "bignumber.js"; import { plainToInstance } from "class-transformer"; import { validate } from "class-validator"; +import { OracleDefinition } from "./OracleDefinition"; import { ExternalToken } from "./OraclePriceAssertion"; import { TokenInstanceKey } from "./TokenInstance"; import { createValidDTO, createValidSubmitDTO } from "./dtos"; @@ -234,4 +235,31 @@ describe("oracle.ts", () => { }) ).rejects.toThrow(/authorities/); }); + + test.each([[" "], ["foo"], ["0x0000000000000000000000000000000000000001"]])( + "oracle authorities reject %p as it is not a user alias", + async (authority) => { + // When & Then + await expect( + createValidSubmitDTO(OracleDefinitionDto, { + name: mockOracle, + authorities: ["client|oracle", authority] + }) + ).rejects.toThrow(/authorities/); + } + ); + + test("oracle definition rejects authorities that are not user aliases", async () => { + // Given + const definition = plainToInstance(OracleDefinition, { + name: mockOracle, + authorities: ["client|oracle", "EthereumBridge", " "] + }); + + // When + const errors = await validate(definition); + + // Then + expect(errors.map((e) => e.property)).toEqual(["authorities"]); + }); }); diff --git a/chain-api/src/types/oracle.ts b/chain-api/src/types/oracle.ts index 5e52a3cc41..51cd81a887 100644 --- a/chain-api/src/types/oracle.ts +++ b/chain-api/src/types/oracle.ts @@ -35,7 +35,7 @@ import { import { JSONSchema } from "class-validator-jsonschema"; import { ValidationFailedError } from "../utils"; -import { BigNumberProperty } from "../validators"; +import { BigNumberProperty, IsUserAlias } from "../validators"; import { OracleDefinition } from "./OracleDefinition"; import { ExactlyOneBaseTokenConstraint, @@ -67,8 +67,7 @@ export class OracleDefinitionDto extends SubmitCallDTO { @IsOptional() @ArrayNotEmpty() @ArrayUnique() - @IsString({ each: true }) - @IsNotEmpty({ each: true }) + @IsUserAlias({ each: true }) public authorities?: string[]; } From 67ea0daacb32be981ad02b252c77eb5bd0a75f9e Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 29 Oct 2026 18:22:31 +0100 Subject: [PATCH 2/7] Fix: Normalize operation ids and keep mint and allowance records A call routed to the default contract without a Contract: prefix gets the same operation id clients sign. Expired swap cleanup skips index entries that are already gone. A fulfillment for another owner in the same transaction is kept as its own record. A grant that would overwrite an unused allowance from the same transaction is rejected. Mint supply uses the smallest baseline among records with the same time key. The external invocation guard normalizes name/channel targets and empty channels. Co-Authored-By: Claude Opus 5.5 --- .../src/allowances/grantAllowance.spec.ts | 42 ++++++++++++ chaincode/src/allowances/grantAllowance.ts | 19 ++++++ chaincode/src/contracts/GalaContract.spec.ts | 32 +++++++++ chaincode/src/contracts/GalaTransaction.ts | 5 ++ .../src/contracts/authorizeOperation.spec.ts | 34 +++++++++- chaincode/src/mint/batchMintToken.spec.ts | 68 +++++++++++++++++++ chaincode/src/mint/fetchMintSupply.ts | 34 ++++++---- chaincode/src/mint/mintToken.spec.ts | 44 ++++++++++++ chaincode/src/mint/mintToken.ts | 48 +++++++++---- chaincode/src/swaps/cleanExpiredSwaps.spec.ts | 26 +++++++ chaincode/src/swaps/cleanExpiredSwaps.ts | 47 +++++++------ chaincode/src/types/GalaChainContext.ts | 11 ++- chaincode/src/types/GalaChainStub.spec.ts | 32 +++++++++ chaincode/src/types/GalaChainStub.ts | 11 ++- chaincode/src/types/OperationContext.ts | 7 +- 15 files changed, 406 insertions(+), 54 deletions(-) create mode 100644 chaincode/src/mint/batchMintToken.spec.ts diff --git a/chaincode/src/allowances/grantAllowance.spec.ts b/chaincode/src/allowances/grantAllowance.spec.ts index d6f87ce991..29293ecec9 100644 --- a/chaincode/src/allowances/grantAllowance.spec.ts +++ b/chaincode/src/allowances/grantAllowance.spec.ts @@ -14,6 +14,7 @@ */ import { AllowanceType, + ChainObject, GalaChainResponse, GrantAllowanceDto, TokenAllowance, @@ -741,4 +742,45 @@ describe("GrantAllowance", () => { expect(getWrites()).toEqual(writesMap(allowance, mintAllowance, mintAllowanceRequest)); }); + + it("rejects a second mint allowance grant to the same user in the same transaction", async () => { + // Given + const nftClass = nft.tokenClass(); + const { collection, category, type, additionalKey } = nft.tokenInstance1KeyPlain(); + const tokenInstance = await createValidDTO(TokenInstanceQueryKey, { + collection, + category, + type, + additionalKey, + instance: TokenInstance.FUNGIBLE_TOKEN_INSTANCE + }); + + const { ctx, contract, getWrites } = fixture(GalaChainTokenContract) + .registeredUsers(users.admin) + .savedState(nftClass, nft.tokenInstance1()); + + const grantDto = (quantity: BigNumber) => + createValidSubmitDTO(GrantAllowanceDto, { + tokenInstance, + quantities: [{ user: users.testUser1.identityKey, quantity }], + allowanceType: AllowanceType.Mint, + uses: new BigNumber("1") + }) + .withOperation("asset-channel_basic-asset_GalaChainToken:GrantAllowance") + .signed(users.admin.privateKey); + + // When + const first = await contract.GrantAllowance(ctx, await grantDto(new BigNumber("100"))); + const second = await contract.GrantAllowance(ctx, await grantDto(new BigNumber("50"))); + + // Then + expect(first).toEqual(GalaChainResponse.Success(expect.any(Array))); + expect(second).toEqual(expect.objectContaining({ Status: 0, ErrorKey: "DUPLICATE_ALLOWANCE" })); + + const allowances = Object.entries(getWrites()) + .filter(([key]) => key.startsWith(`\u0000${TokenAllowance.INDEX_KEY}\u0000`)) + .map(([, value]) => ChainObject.deserialize(TokenAllowance, value)); + + expect(allowances.map((a) => a.quantity.toString())).toEqual(["100"]); + }); }); diff --git a/chaincode/src/allowances/grantAllowance.ts b/chaincode/src/allowances/grantAllowance.ts index c0ebc81c11..bed5b052b7 100644 --- a/chaincode/src/allowances/grantAllowance.ts +++ b/chaincode/src/allowances/grantAllowance.ts @@ -45,6 +45,7 @@ import { } from "../token/TokenError"; import { GalaChainContext } from "../types/GalaChainContext"; import { + ObjectNotFoundError, getObjectByKey, getObjectsByPartialCompositeKey, inverseEpoch, @@ -373,6 +374,10 @@ async function putAllowancesOnChain( await preventDuplicateAllowance(ctx, newAllowance); } + // Allowances granted in one transaction share the same created time, so a repeated grant would overwrite + // an allowance that has not been used yet. + await preventAllowanceOverwrite(ctx, newAllowance); + await putChainObject(ctx, newAllowance); // todo: this code appears to be writing an un-modified tokenClass on every mint allowance grant...? // I don't see code that increments the totalSupply, totalMintAllowanceSupply, etc. @@ -597,6 +602,20 @@ export async function grantAllowance( } } +async function preventAllowanceOverwrite(ctx: GalaChainContext, tokenAllowance: TokenAllowance) { + const key = tokenAllowance.getCompositeKey(); + const existing = await getObjectByKey(ctx, TokenAllowance, key).catch((e) => { + if (e instanceof ObjectNotFoundError) { + return undefined; + } + throw e; + }); + + if (existing !== undefined && allowanceIsUseable(ctx, existing)) { + throw new DuplicateAllowanceError(key, instanceToPlain(tokenAllowance)); + } +} + export async function preventDuplicateAllowance( ctx: GalaChainContext, tokenAllowance: TokenAllowance diff --git a/chaincode/src/contracts/GalaContract.spec.ts b/chaincode/src/contracts/GalaContract.spec.ts index 7fb618a326..db6e651f95 100644 --- a/chaincode/src/contracts/GalaContract.spec.ts +++ b/chaincode/src/contracts/GalaContract.spec.ts @@ -971,3 +971,35 @@ describe("transaction expiration", () => { expect(response).toEqual(transactionErrorKey("EXPIRED")); }); }); + +describe("GalaContract operation id", () => { + it("should accept a DTO signed for Contract:Method when the call has no contract prefix", async () => { + // Given + const chaincode = new TestChaincode([TestGalaContract]); + const adminPrivateKey = process.env.DEV_ADMIN_PRIVATE_KEY as string; + const dto = SuperheroDto.create("Batman", 32) + .withOperation("asset-channel_basic-asset_TestGalaContract:CreateSuperhero") + .signed(adminPrivateKey); + + // When + const response = await chaincode.invoke("CreateSuperhero", dto.serialize()); + + // Then + expect(response).toEqual(transactionSuccess()); + }); + + it("should reject a DTO signed for a bare method name", async () => { + // Given + const chaincode = new TestChaincode([TestGalaContract]); + const adminPrivateKey = process.env.DEV_ADMIN_PRIVATE_KEY as string; + const dto = SuperheroDto.create("Batman", 32) + .withOperation("asset-channel_basic-asset_CreateSuperhero") + .signed(adminPrivateKey); + + // When + const response = await chaincode.invoke("CreateSuperhero", dto.serialize()); + + // Then + expect(response).toEqual(transactionErrorKey("UNAUTHORIZED")); + }); +}); diff --git a/chaincode/src/contracts/GalaTransaction.ts b/chaincode/src/contracts/GalaTransaction.ts index 750482789c..d094661bf5 100644 --- a/chaincode/src/contracts/GalaTransaction.ts +++ b/chaincode/src/contracts/GalaTransaction.ts @@ -178,6 +178,11 @@ function GalaTransaction( // convention. // eslint-disable-next-line no-param-reassign descriptor.value = async function (ctx: GalaChainContext, dtoPlain) { + const contractName = (this as { getName?: () => string } | undefined)?.getName?.(); + if (contractName && typeof ctx.setContractName === "function") { + ctx.setContractName(contractName); + } + // Top-level invokes already have a SERVER span from GalaContract.aroundTransaction. // Nested batch ops use a fresh ctx — start one here. ctx.otel.start(loggingContext, dtoPlain, { diff --git a/chaincode/src/contracts/authorizeOperation.spec.ts b/chaincode/src/contracts/authorizeOperation.spec.ts index a7a274cb17..956f7b5e4e 100644 --- a/chaincode/src/contracts/authorizeOperation.spec.ts +++ b/chaincode/src/contracts/authorizeOperation.spec.ts @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { GalaChainContext } from "../types"; +import { GalaChainContext, GalaChainStub } from "../types"; import { ensureCorrectMethodIsUsed } from "./authorize"; type SignedDto = NonNullable[1]>; @@ -81,4 +81,36 @@ describe("ensureCorrectMethodIsUsed", () => { /FreezeTokenBalance/ ); }); + + it("accepts a DTO signed for Contract:Method when the call has no contract prefix", () => { + // Given + const previousChaincodeId = process.env.GC_CHAINCODE_ID; + process.env.GC_CHAINCODE_ID = chaincode; + + const ctx = new GalaChainContext({}); + ctx.stub = { + getChannelID: () => channel, + getFunctionAndParameters: () => ({ fcn: "TransferToken", params: [] }) + } as unknown as GalaChainStub; + ctx.callingUserData = { + roles: [], + signedBy: [], + signatureQuorum: 1, + allowedSigners: [], + isMultisig: false + }; + ctx.setContractName("GalaChainToken"); + + try { + // When & Then + expect(ctx.operationCtx.fullOperationId).toEqual(operation); + expect(() => ensureCorrectMethodIsUsed(ctx, signedDto(operation))).not.toThrow(); + } finally { + if (previousChaincodeId === undefined) { + delete process.env.GC_CHAINCODE_ID; + } else { + process.env.GC_CHAINCODE_ID = previousChaincodeId; + } + } + }); }); diff --git a/chaincode/src/mint/batchMintToken.spec.ts b/chaincode/src/mint/batchMintToken.spec.ts new file mode 100644 index 0000000000..b39278ccbb --- /dev/null +++ b/chaincode/src/mint/batchMintToken.spec.ts @@ -0,0 +1,68 @@ +/* + * 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 { RangedChainObject, TokenMintRequest, UserAlias, asValidUserAlias } from "@gala-chain/api"; +import { fixture, users } from "@gala-chain/test"; +import BigNumber from "bignumber.js"; + +import GalaChainTokenContract from "../__test__/GalaChainTokenContract"; +import { currency } from "../testdata"; +import { GalaChainContext } from "../types"; +import { batchMintToken } from "./batchMintToken"; +import { fetchMintSupply } from "./fetchMintSupply"; + +describe("batchMintToken", () => { + it("should report the supply of a batch minting to several owners", async () => { + // Given + const alice = asValidUserAlias("client|alice"); + const bob = asValidUserAlias("client|bob"); + const tokenAllowance = currency.tokenAllowance(); + tokenAllowance.uses = new BigNumber("2"); + + const { ctx, getWrites } = fixture(GalaChainTokenContract) + .registeredUsers(users.admin) + .callingUser(users.admin) + .savedState(currency.tokenClass(), currency.tokenInstance(), tokenAllowance) + .savedRangeState([]); + + const mintTo = (owner: UserAlias) => ({ + tokenClassKey: currency.tokenClassKey(), + owner, + quantity: new BigNumber("1"), + authorizedOnBehalf: undefined + }); + + await batchMintToken(ctx, [mintTo(alice), mintTo(bob)]); + await ctx.stub.flushWrites(); + + const mintRequests = Object.entries(getWrites()) + .filter(([key]) => key.startsWith(TokenMintRequest.INDEX_KEY)) + .map(([, value]) => RangedChainObject.deserialize(TokenMintRequest, value)); + + expect(mintRequests.map((r) => [r.owner, r.totalKnownMintsCount.toString()])).toEqual([ + [alice, "0"], + [bob, "1"] + ]); + + const { ctx: nextCtx } = fixture( + GalaChainTokenContract + ).savedRangeState(mintRequests); + + // When + const supply = await fetchMintSupply(nextCtx, currency.tokenClassKey(), 0); + + // Then + expect(supply).toEqual(new BigNumber("2")); + }); +}); diff --git a/chaincode/src/mint/fetchMintSupply.ts b/chaincode/src/mint/fetchMintSupply.ts index 33a37f9cc2..543b94ab0a 100644 --- a/chaincode/src/mint/fetchMintSupply.ts +++ b/chaincode/src/mint/fetchMintSupply.ts @@ -61,13 +61,19 @@ export async function fetchMintSupply( try { for await (const kv of iterator) { - if (!seekingFirstResult && resultsCount >= minResults) { - break; - } if (kv.value) { const stringResult = Buffer.from(kv.value).toString("utf8"); const entry = RangedChainObject.deserialize(TokenMintRequest, stringResult); + // Stop only between time keys, so all records of the oldest transaction are read. + if ( + !seekingFirstResult && + resultsCount >= minResults && + entry.timeKey !== previousRequests[0].timeKey + ) { + break; + } + // timeKey is a string padded with leading zeros. BigNumber will parse into an integer. // const entryTime = new BigNumber(entry.timeKey); @@ -88,22 +94,26 @@ export async function fetchMintSupply( throw new Error(`Failed to get iterator for getStateByRange with key: ${startKey}, ${iterator}, ${e}`); } - let startingKnownMintsCount: BigNumber = new BigNumber("0"); + // The oldest records set the baseline. A batch writes several records with one time key and + // a growing baseline for each, so use the smallest baseline among them. + const oldestTimeKey = previousRequests[0]?.timeKey; let updatedKnownMintsCount: BigNumber = new BigNumber("0"); - - let firstResult = true; + let baseline: BigNumber | undefined; for (const entry of previousRequests) { - if (firstResult && entry.totalKnownMintsCount.isGreaterThan(startingKnownMintsCount)) { - // establish baseline for first result - startingKnownMintsCount = entry.totalKnownMintsCount; - updatedKnownMintsCount = new BigNumber(entry.totalKnownMintsCount); + if ( + entry.timeKey === oldestTimeKey && + (baseline === undefined || entry.totalKnownMintsCount.isLessThan(baseline)) + ) { + baseline = entry.totalKnownMintsCount; } - firstResult = false; - updatedKnownMintsCount = updatedKnownMintsCount.plus(entry.quantity); } + if (baseline !== undefined && baseline.isGreaterThan(0)) { + updatedKnownMintsCount = updatedKnownMintsCount.plus(baseline); + } + return updatedKnownMintsCount; } diff --git a/chaincode/src/mint/mintToken.spec.ts b/chaincode/src/mint/mintToken.spec.ts index 64993d7118..5e2530947c 100644 --- a/chaincode/src/mint/mintToken.spec.ts +++ b/chaincode/src/mint/mintToken.spec.ts @@ -24,6 +24,7 @@ import { TokenMintFulfillment, TokenMintRequest, TokenMintStatus, + UserAlias, createValidChainObject, createValidDTO, createValidRangedChainObject, @@ -224,6 +225,49 @@ describe("MintToken", () => { ); }); + test("keeps the fulfillment of each owner minted in the same transaction", async () => { + // Given + const currencyClass = currency.tokenClass(); + const tokenAllowance = currency.tokenAllowance(); + tokenAllowance.uses = new BigNumber("2"); + + const { ctx, contract, getWrites } = fixture(GalaChainTokenContract) + .registeredUsers(users.admin) + .savedState(currencyClass, currency.tokenInstance(), tokenAllowance) + .savedRangeState([]); + + const mintDto = (owner: UserAlias, quantity: BigNumber) => + createValidSubmitDTO(MintTokenDto, { tokenClass: currency.tokenClassKey(), owner, quantity }) + .withOperation("asset-channel_basic-asset_GalaChainToken:MintToken") + .signed(users.admin.privateKey); + + // When + const firstResponse = await contract.MintToken( + ctx, + await mintDto(users.testUser1.identityKey, new BigNumber("1")) + ); + const secondResponse = await contract.MintToken( + ctx, + await mintDto(users.testUser2.identityKey, new BigNumber("2")) + ); + + // Then + expect(firstResponse).toEqual(GalaChainResponse.Success([currency.tokenInstanceKey()])); + expect(secondResponse).toEqual(GalaChainResponse.Success([currency.tokenInstanceKey()])); + + const fulfillments = Object.entries(getWrites()) + .filter(([key]) => key.startsWith(`\u0000${TokenMintFulfillment.INDEX_KEY}\u0000`)) + .map(([key, value]) => ({ key, ...(JSON.parse(value) as { owner: string; quantity: string }) })); + + expect(fulfillments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ owner: users.testUser1.identityKey, quantity: "1" }), + expect.objectContaining({ owner: users.testUser2.identityKey, quantity: "2" }) + ]) + ); + expect(fulfillments).toHaveLength(2); + }); + test("mints unique items, i.e. NFTs", async () => { // Given const nftInstance = nft.tokenInstance1(); diff --git a/chaincode/src/mint/mintToken.ts b/chaincode/src/mint/mintToken.ts index 859e5783d3..156724f83a 100644 --- a/chaincode/src/mint/mintToken.ts +++ b/chaincode/src/mint/mintToken.ts @@ -15,6 +15,7 @@ import { AllowanceType, AuthorizedOnBehalf, + ChainObject, MintTokenDto, TokenAllowance, TokenClass, @@ -250,20 +251,39 @@ export async function updateTotalSupply( const mintFulfillmentEntry: TokenMintFulfillment = mintRequest.fulfill(quantity); - // A second mint in this transaction uses the same fulfillment key. Add its quantity when the owner matches. - try { - const pending = await getObjectByKey(ctx, TokenMintFulfillment, mintFulfillmentEntry.getCompositeKey()); - if (pending.owner === mintFulfillmentEntry.owner) { - mintFulfillmentEntry.quantity = pending.quantity.plus(quantity); - mintFulfillmentEntry.state = mintFulfillmentEntry.quantity.isLessThan(mintRequest.quantity) - ? TokenMintStatus.PartiallyMinted - : TokenMintStatus.Minted; - } - } catch (e) { - if (!(e instanceof ObjectNotFoundError)) { - throw e; - } + // Fulfillments in one transaction share a key per requestor. A second mint for the same owner + // adds to the existing record. A mint for another owner gets its own key with the owner appended. + let key = mintFulfillmentEntry.getCompositeKey(); + let pending = await findMintFulfillment(ctx, key); + + if (pending !== undefined && pending.owner !== mintFulfillmentEntry.owner) { + key = `${key}${mintFulfillmentEntry.owner}${ChainObject.MIN_UNICODE_RUNE_VALUE}`; + pending = await findMintFulfillment(ctx, key); + } + + if (pending !== undefined) { + mintFulfillmentEntry.quantity = pending.quantity.plus(quantity); + mintFulfillmentEntry.state = mintFulfillmentEntry.quantity.isLessThan(mintRequest.quantity) + ? TokenMintStatus.PartiallyMinted + : TokenMintStatus.Minted; + } + + if (key === mintFulfillmentEntry.getCompositeKey()) { + await putChainObject(ctx, mintFulfillmentEntry); + } else { + await mintFulfillmentEntry.validateOrReject(); + await ctx.stub.putState(key, Buffer.from(mintFulfillmentEntry.serialize())); } +} - await putChainObject(ctx, mintFulfillmentEntry); +async function findMintFulfillment( + ctx: GalaChainContext, + key: string +): Promise { + return getObjectByKey(ctx, TokenMintFulfillment, key).catch((e) => { + if (e instanceof ObjectNotFoundError) { + return undefined; + } + throw e; + }); } diff --git a/chaincode/src/swaps/cleanExpiredSwaps.spec.ts b/chaincode/src/swaps/cleanExpiredSwaps.spec.ts index b52697b5c9..b22a3e425a 100644 --- a/chaincode/src/swaps/cleanExpiredSwaps.spec.ts +++ b/chaincode/src/swaps/cleanExpiredSwaps.spec.ts @@ -93,4 +93,30 @@ describe("cleanTokenSwaps use comparison", () => { expect(response.deletes.map((deleted) => deleted.swapRequestId)).toEqual([swap.swapRequestId]); }); + + it("deletes an expired swap that lists the same token twice", async () => { + // Given + const swap = swapWithUses("1", "1"); + swap.offered = [swap.offered[0], swap.offered[0]]; + const other = swapWithUses("2", "2"); + const { ctx, getWrites } = fixture(GalaChainTokenContract).savedState( + swap, + ...indexesFor(swap), + other, + ...indexesFor(other) + ); + + // When + const response = await cleanTokenSwaps(ctx, {}); + + // Then + expect(response.deletes.map((deleted) => deleted.swapRequestId).sort()).toEqual( + [swap.swapRequestId, other.swapRequestId].sort() + ); + const deletedKeys = [swap, ...indexesFor(swap), other, ...indexesFor(other)].map((o) => + o.getCompositeKey() + ); + await ctx.stub.flushWrites(); + expect(Object.keys(getWrites()).sort()).toEqual(deletedKeys.sort()); + }); }); diff --git a/chaincode/src/swaps/cleanExpiredSwaps.ts b/chaincode/src/swaps/cleanExpiredSwaps.ts index c2bdb6a964..269140ee01 100644 --- a/chaincode/src/swaps/cleanExpiredSwaps.ts +++ b/chaincode/src/swaps/cleanExpiredSwaps.ts @@ -15,16 +15,18 @@ import { ChainObject, CleanTokenSwapsResponse, + Inferred, TokenSwapRequest, TokenSwapRequestInstanceOffered, TokenSwapRequestInstanceWanted, TokenSwapRequestOfferedBy, TokenSwapRequestOfferedTo } from "@gala-chain/api"; -import { plainToInstance } from "class-transformer"; +import { ClassConstructor, plainToInstance } from "class-transformer"; import { GalaChainContext } from "../types"; import { + ObjectNotFoundError, deleteChainObject, getObjectByKey, getObjectsByKeys, @@ -65,6 +67,25 @@ export async function cleanTokenSwaps( return response; } +// An index entry may already be gone, e.g. when a swap lists the same token twice. +// Skip it, so that one such record does not block the cleanup. +async function deleteIndexIfExists( + ctx: GalaChainContext, + constructor: ClassConstructor>, + key: string +): Promise { + const index = await getObjectByKey(ctx, constructor, key).catch((e) => { + if (e instanceof ObjectNotFoundError) { + return undefined; + } + throw e; + }); + + if (index !== undefined) { + await deleteChainObject(ctx, index); + } +} + export async function deindexTokenSwapRequest(ctx: GalaChainContext, swap: TokenSwapRequest) { const { offeredBy, offeredTo, offered, wanted, swapRequestId } = swap; @@ -73,9 +94,7 @@ export async function deindexTokenSwapRequest(ctx: GalaChainContext, swap: Token swapRequestId ]); - const offeredByIndex = await getObjectByKey(ctx, TokenSwapRequestOfferedBy, offeredByIndexKey); - - await deleteChainObject(ctx, offeredByIndex); + await deleteIndexIfExists(ctx, TokenSwapRequestOfferedBy, offeredByIndexKey); if (offeredTo) { const offeredToIndexKey = ChainObject.getCompositeKeyFromParts(TokenSwapRequestOfferedTo.INDEX_KEY, [ @@ -83,9 +102,7 @@ export async function deindexTokenSwapRequest(ctx: GalaChainContext, swap: Token swapRequestId ]); - const offeredToIndex = await getObjectByKey(ctx, TokenSwapRequestOfferedTo, offeredToIndexKey); - - await deleteChainObject(ctx, offeredToIndex); + await deleteIndexIfExists(ctx, TokenSwapRequestOfferedTo, offeredToIndexKey); } for (const offeredTokenQty of offered) { @@ -96,13 +113,7 @@ export async function deindexTokenSwapRequest(ctx: GalaChainContext, swap: Token [collection, category, type, additionalKey, instance.toString(), swapRequestId] ); - const offeredInstanceIndex = await getObjectByKey( - ctx, - TokenSwapRequestInstanceOffered, - offeredInstanceIndexKey - ); - - await deleteChainObject(ctx, offeredInstanceIndex); + await deleteIndexIfExists(ctx, TokenSwapRequestInstanceOffered, offeredInstanceIndexKey); } for (const wantedTokenQty of wanted) { @@ -113,13 +124,7 @@ export async function deindexTokenSwapRequest(ctx: GalaChainContext, swap: Token [collection, category, type, additionalKey, instance.toString(), swapRequestId] ); - const wantedInstanceIndex = await getObjectByKey( - ctx, - TokenSwapRequestInstanceWanted, - wantedInstanceIndexKey - ); - - await deleteChainObject(ctx, wantedInstanceIndex); + await deleteIndexIfExists(ctx, TokenSwapRequestInstanceWanted, wantedInstanceIndexKey); } await deleteChainObject(ctx, swap); diff --git a/chaincode/src/types/GalaChainContext.ts b/chaincode/src/types/GalaChainContext.ts index 5cd46a893b..09e8f931a6 100644 --- a/chaincode/src/types/GalaChainContext.ts +++ b/chaincode/src/types/GalaChainContext.ts @@ -178,6 +178,7 @@ export class GalaChainContext extends Context { private callingUserAllowedSignersValue?: UserAlias[]; private isMultisigValue?: boolean; private operationCtxValue?: OperationContext; + private contractNameValue?: string; private txUnixTimeValue?: number; private loggerInstance?: GalaLoggerInstance; @@ -302,11 +303,19 @@ export class GalaChainContext extends Context { get operationCtx(): OperationContext { if (this.operationCtxValue === undefined) { - this.operationCtxValue = getOperationContext(this); + this.operationCtxValue = getOperationContext(this, this.contractNameValue); } return { ...this.operationCtxValue }; // prevent mutation } + /** Name of the contract handling the call, used to resolve a method name without a contract prefix. */ + public setContractName(contractName: string): void { + this.contractNameValue = contractName; + if (this.operationCtxValue !== undefined && !this.operationCtxValue.methodName.includes(":")) { + this.operationCtxValue = undefined; + } + } + // The batch stub still names the outer call. An inner DTO is checked against Contract:method, // the same name a direct call uses. public bindOperationMethod(methodName: string): void { diff --git a/chaincode/src/types/GalaChainStub.spec.ts b/chaincode/src/types/GalaChainStub.spec.ts index 19c541fb38..d31d4c6817 100644 --- a/chaincode/src/types/GalaChainStub.spec.ts +++ b/chaincode/src/types/GalaChainStub.spec.ts @@ -479,6 +479,38 @@ describe("invokeChaincode", () => { expect(internalStub.invokeChaincode).toHaveBeenCalledWith("chaincode-1", args, ""); }); + it("should treat a chaincode name with a channel suffix as the same target", async () => { + // Given + const { internalStub, gcStub } = setupTest(); + const args = ["Contract:Method", "{}"]; + + // When + const first = await gcStub.invokeChaincode("chaincode-1/channel-B", args, ""); + const second = await gcStub.invokeChaincode("chaincode-1", args, "channel-B").catch((e) => e); + + // Then + expect(first).toEqual(successInvoke); + expect(second).toEqual(new DuplicateInvokeChaincodeError("chaincode-1", args, "channel-B")); + expect(internalStub.invokeChaincode).toHaveBeenCalledTimes(1); + expect(internalStub.invokeChaincode).toHaveBeenCalledWith("chaincode-1/channel-B", args, ""); + }); + + it.each([null, undefined])("should treat channel %s as the current channel", async (channel) => { + // Given + const { internalStub, gcStub } = setupTest(); + const args = ["Contract:Method", "{}"]; + internalStub.getChannelID = jest.fn(() => "channel-A"); + + // When + const first = await gcStub.invokeChaincode("chaincode-1", args, channel as unknown as string); + const second = await gcStub.invokeChaincode("chaincode-1", args, "channel-A").catch((e) => e); + + // Then + expect(first).toEqual(successInvoke); + expect(second).toEqual(new DuplicateInvokeChaincodeError("chaincode-1", args, "channel-A")); + expect(internalStub.invokeChaincode).toHaveBeenCalledTimes(1); + }); + it("should record external chaincode invocation", async () => { // Given const { gcStub } = setupTest(); diff --git a/chaincode/src/types/GalaChainStub.ts b/chaincode/src/types/GalaChainStub.ts index ac66a80360..77b5cc388b 100644 --- a/chaincode/src/types/GalaChainStub.ts +++ b/chaincode/src/types/GalaChainStub.ts @@ -187,12 +187,17 @@ class StubCache { "fabric.invoke.method": args?.[0] ?? "" }, async () => { - const effectiveChannel = channel === "" ? this.stub.getChannelID() : channel; - const key = `${effectiveChannel}/${chaincodeName}`; + // Fabric also accepts "name/channel" as the chaincode name with an empty channel, + // and treats an empty channel as the current one. Normalize both before the check. + const separator = chaincodeName.indexOf("/"); + const nameChannel = !channel && separator >= 0 ? chaincodeName.slice(separator + 1) : ""; + const targetName = nameChannel ? chaincodeName.slice(0, separator) : chaincodeName; + const effectiveChannel = channel || nameChannel || this.stub.getChannelID(); + const key = `${effectiveChannel}/${targetName}`; const prevCall = this.invokeChaincodeCalls[key]; if (prevCall) { - throw new DuplicateInvokeChaincodeError(chaincodeName, prevCall, effectiveChannel); + throw new DuplicateInvokeChaincodeError(targetName, prevCall, effectiveChannel); } const isDryRun = args?.[0].endsWith(":DryRun"); diff --git a/chaincode/src/types/OperationContext.ts b/chaincode/src/types/OperationContext.ts index 0a228d8300..294b065031 100644 --- a/chaincode/src/types/OperationContext.ts +++ b/chaincode/src/types/OperationContext.ts @@ -23,10 +23,13 @@ export interface OperationContext { fullOperationId: string; } -export function getOperationContext(ctx: Context): OperationContext { +export function getOperationContext(ctx: Context, contractName?: string): OperationContext { const channelId = ctx.stub.getChannelID(); const chaincodeId = getChaincodeId(ctx); - const methodName = ctx.stub.getFunctionAndParameters().fcn; + const fcn = ctx.stub.getFunctionAndParameters().fcn; + // A call routed to the default contract may omit the "Contract:" prefix. + // Use the same Contract:Method form that clients sign and batches bind. + const methodName = contractName && fcn && !fcn.includes(":") ? `${contractName}:${fcn}` : fcn; const fullOperationId = `${channelId}_${chaincodeId}_${methodName}`; return { channelId, chaincodeId, methodName, fullOperationId }; From a9512cf0926e86f339c8c186efc5742809db85f1 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 29 Oct 2026 18:22:31 +0100 Subject: [PATCH 3/7] Fix: Match the chain domain name and harden connect signing and requests The EIP-712 domain name follows the same network rule as the chaincode. Adding a wallet signature to a multisig DTO reuses its types, domain and prefix. Object arrays merge the fields of every element, and identical nested types may repeat. FetchTokenClassesWithSupply is unsigned. The request timeout covers the response body, with a size limit and caller abort signal. ethereumToGalaChainAddress replaces only a leading 0x, and examples no longer log secrets. Co-Authored-By: Claude Opus 5.5 --- chain-connect/docs/Examples.md | 62 ++++---- chain-connect/src/GalaChainClient.spec.ts | 16 +- chain-connect/src/GalaChainClient.ts | 26 ++-- .../src/chainApis/GalaChainBaseApi.ts | 1 + chain-connect/src/chainApis/TokenApi.ts | 2 +- .../BrowserConnectClient.spec.ts | 132 ++++++++++++++++ .../src/customClients/BrowserConnectClient.ts | 92 ++++++++--- chain-connect/src/eip712DomainName.spec.ts | 14 +- chain-connect/src/eip712DomainName.ts | 13 +- chain-connect/src/fetchWithTimeout.spec.ts | 144 +++++++++++++++++- chain-connect/src/fetchWithTimeout.ts | 109 ++++++++++++- chain-connect/src/utils/CommonUtils.spec.ts | 98 +++++++++++- chain-connect/src/utils/CommonUtils.ts | 117 ++++++++------ chain-connect/src/utils/WalletUtils.spec.ts | 2 +- chain-connect/src/utils/WalletUtils.ts | 2 +- chain-connect/test/test-utils.ts | 1 + 16 files changed, 701 insertions(+), 130 deletions(-) create mode 100644 chain-connect/src/customClients/BrowserConnectClient.spec.ts diff --git a/chain-connect/docs/Examples.md b/chain-connect/docs/Examples.md index d05137f31e..4241bca1c1 100644 --- a/chain-connect/docs/Examples.md +++ b/chain-connect/docs/Examples.md @@ -29,35 +29,36 @@ First, you need to create a client and connect to a web3 wallet, the below shows ```typescript import { BrowserConnectClient } from "@gala-chain/connect"; -const web3Wallet = new BrowserConnectClient(); +// Mainnet apps set network: "MNT". Without it the EIP-712 domain name is GalaChainDev. +const web3Wallet = new BrowserConnectClient(undefined, { network: "MNT" }); export async function connectToWeb3Wallet() { - try { - const connectionResult = await web3Wallet.connect(); - console.log(`User connected with wallet: ${connectionResult}`) - - // Listening to account changes - web3Wallet.on("accountChanged", (account: string[] | string | null) => { - if (Array.isArray(account)) { - //For simplicity of the example,we'll just grab the first connected wallet - if (account.length > 0) { - connectedUser.value = account[0]; - } - } else { - console.log(`Account changed: ${account}`); - } - }); - } catch (error) { - message.value = "Failed to connect!"; - console.error(error); - } + try { + const connectionResult = await web3Wallet.connect(); + console.log(`User connected with wallet: ${connectionResult}`); + + // Listening to account changes + web3Wallet.on("accountChanged", (account: string[] | string | null) => { + if (Array.isArray(account)) { + //For simplicity of the example,we'll just grab the first connected wallet + if (account.length > 0) { + connectedUser.value = account[0]; + } + } else { + console.log(`Account changed: ${account}`); + } + }); + } catch (error) { + message.value = "Failed to connect!"; + console.error(error); + } } export function isConnected() { - if (Array.isArray(connectedUser.value)) { - return connectedUser.value.length > 0; - } - return connectedUser.value !== null; + if (Array.isArray(connectedUser.value)) { + return connectedUser.value.length > 0; + } + return connectedUser.value !== null; } ``` @@ -94,7 +95,6 @@ export async function createTokenClass() { } ``` - ### Generating Wallets You can generate new wallets using the `WalletUtils` utility provided by GalaChain Connect. @@ -165,9 +165,7 @@ export async function generateWallet() { ); console.log(`Wallet generated and registered. Address: ${wallet.ethAddress}`); createdUser.value = wallet.ethAddress; - - //This is unsafe, please don't use in production - console.log("Private Key:", wallet.privateKey); + // privateKey and mnemonic are secrets. Do not log them. } catch (error) { console.log("Failed to generate wallet!"); console.error(error); @@ -185,7 +183,9 @@ GalaChain Connect supports server-side signing using a private key. This is usef import { PublicKeyApi, SigningClient } from "@gala-chain/connect"; const privateKey = "your-private-key-here"; -const connection = new SigningClient(privateKey); +// The gateway below is on stage, so the EIP-712 domain is selected with network: "STG". +// Mainnet apps set network: "MNT". +const connection = new SigningClient(privateKey, { network: "STG" }); // The below URI is an example; you can use any URI that supports this const uri = @@ -215,7 +215,8 @@ You can also create a token class on the server side: import { SigningClient, TokenApi } from "@gala-chain/connect"; const privateKey = "your-private-key-here"; -const connection = new SigningClient(privateKey); +// Mainnet apps set network: "MNT" so signatures use the GalaChain EIP-712 domain. +const connection = new SigningClient(privateKey, { network: "MNT" }); const tokenClient = new TokenApi("https://your-galachain-api-url/asset/token-contract", connection); @@ -249,5 +250,6 @@ createTokenClass(); - **Token Data**: Adjust `arbitraryTokenData` to match the specific token class details you wish to create. - **Image Field**: The `image` field in `arbitraryTokenData` should point to a valid image URL +- **Network**: The `network` option selects the EIP-712 domain name used for signing: `MNT` uses `GalaChain`, `STG` uses `GalaChainStage`, and anything else (including no value) uses `GalaChainDev`. Mainnet apps must set `network: "MNT"`, or pass `domainName` explicitly. --- diff --git a/chain-connect/src/GalaChainClient.spec.ts b/chain-connect/src/GalaChainClient.spec.ts index 350c63bfeb..97295ce5f3 100644 --- a/chain-connect/src/GalaChainClient.spec.ts +++ b/chain-connect/src/GalaChainClient.spec.ts @@ -437,19 +437,29 @@ describe("BrowserConnectClient", () => { const client = new BrowserConnectClient(); await client.connect(); + const types = { + TransferToken: [ + { name: "quantity", type: "string" }, + { name: "uniqueKey", type: "string" }, + { name: "signerAddress", type: "string" }, + { name: "dtoOperation", type: "string" }, + { name: "dtoExpiresAt", type: "uint256" } + ] + }; const signed = await client.sign("TransferToken", { quantity: "1", uniqueKey: "k", multisig: ["0xexisting"], signerAddress: "eth|63580d94c574ad78b121c267", dtoOperation: "asset_channel_assets_TransferToken", - dtoExpiresAt: 4000000000000 + dtoExpiresAt: 4000000000000, + types, + domain: { name: "GalaChainDev" } }); expect(signed.signature).toBeUndefined(); expect(signed.multisig).toEqual(["0xexisting", "sampleSignature"]); - const types = (signed as { types?: { TransferToken?: Array<{ name: string }> } }).types; - expect(types?.TransferToken?.map((field) => field.name)).not.toContain("multisig"); + expect((signed as { types?: unknown }).types).toEqual(types); }); }); diff --git a/chain-connect/src/GalaChainClient.ts b/chain-connect/src/GalaChainClient.ts index 14ebf7c944..1495ca781d 100644 --- a/chain-connect/src/GalaChainClient.ts +++ b/chain-connect/src/GalaChainClient.ts @@ -24,7 +24,7 @@ import { instanceToPlain, plainToInstance } from "class-transformer"; import { BrowserProvider } from "ethers"; import { eip712DomainName } from "./eip712DomainName"; -import { DEFAULT_REQUEST_TIMEOUT_MS, fetchWithTimeout } from "./fetchWithTimeout"; +import { fetchWithTimeout } from "./fetchWithTimeout"; import { EventEmitter, Listener, MetaMaskEvents, recoverPublicKeyFromPersonalSign } from "./helpers"; import { GalaChainResponseError, GalaChainResponseSuccess, SigningType } from "./types"; import { ConstructorArgs } from "./types/utils"; @@ -44,14 +44,18 @@ type NonArrayClassConstructor = T extends Array export interface GalaChainProviderOptions { /** The type of signing to use for transactions */ signingType?: SigningType; - /** Milliseconds to wait for a gateway response. Defaults to 30 seconds. */ + /** Milliseconds to wait for a gateway response, including its body. Defaults to 30 seconds. */ timeoutMs?: number; + /** Largest gateway response body accepted, in bytes. Defaults to 10 MB. */ + maxResponseBytes?: number; /** - * EIP-712 domain name. When omitted, network STG uses GalaChainStage, DEV uses GalaChainDev, - * and every other value stays GalaChain. chainId is left to the wallet. + * EIP-712 domain name. Overrides network when set. chainId is left to the wallet. */ domainName?: string; - /** Deployment selector used when domainName is omitted. */ + /** + * Deployment selector used when domainName is omitted: MNT uses GalaChain, STG uses + * GalaChainStage, and DEV or no value uses GalaChainDev. Mainnet apps must set MNT. + */ network?: "MNT" | "STG" | "DEV"; /** Channel used to name dtoOperation when a payload is signed. */ channel?: string; @@ -118,6 +122,7 @@ export abstract class GalaChainProvider { * @param params.requestConstructor - Constructor for request validation * @param params.responseConstructor - Constructor for response transformation * @param params.signingType - Type of signing to use + * @param params.signal - Optional signal that cancels the request * @returns Promise resolving to a successful response * @throws {GalaChainResponseError} When the request fails */ @@ -129,7 +134,8 @@ export abstract class GalaChainProvider { headers = {}, requestConstructor, responseConstructor, - signingType = this.options?.signingType ?? SigningType.SIGN_TYPED_DATA + signingType = this.options?.signingType ?? SigningType.SIGN_TYPED_DATA, + signal }: { url: string; method: string; @@ -139,6 +145,7 @@ export abstract class GalaChainProvider { requestConstructor?: ClassConstructor; responseConstructor?: NonArrayClassConstructor; signingType?: SigningType; + signal?: AbortSignal; }): Promise> { // Throws error if class validation fails. Signing and transport use the // validated instance so constructor transforms are not discarded. @@ -155,15 +162,14 @@ export abstract class GalaChainProvider { } const fullUrl = `${url}/${method}`; - const timeoutMs = this.options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - const response = await fetchWithTimeout( + const { response, body } = await fetchWithTimeout( fullUrl, { method: "POST", body: serialize(newPayload), headers: this.requestHeaders(headers) }, - timeoutMs + { timeoutMs: this.options?.timeoutMs, maxResponseBytes: this.options?.maxResponseBytes, signal } ); const hash = response.headers.get("x-transaction-id") ?? undefined; @@ -172,7 +178,7 @@ export abstract class GalaChainProvider { if (response.headers.get("content-length") !== "0") { let data: any; try { - data = await response.json(); + data = JSON.parse(body); } catch (error) { throw new Error("Invalid JSON response"); } diff --git a/chain-connect/src/chainApis/GalaChainBaseApi.ts b/chain-connect/src/chainApis/GalaChainBaseApi.ts index fa5b36f9d4..8e9cc84229 100644 --- a/chain-connect/src/chainApis/GalaChainBaseApi.ts +++ b/chain-connect/src/chainApis/GalaChainBaseApi.ts @@ -56,6 +56,7 @@ export class GalaChainBaseApi { requestConstructor?: ClassConstructor; responseConstructor?: NonArrayClassConstructor; signingType?: SigningType; + signal?: AbortSignal; }): Promise> { return this.connection.submit({ ...params, diff --git a/chain-connect/src/chainApis/TokenApi.ts b/chain-connect/src/chainApis/TokenApi.ts index 90383ded5f..e717f61241 100644 --- a/chain-connect/src/chainApis/TokenApi.ts +++ b/chain-connect/src/chainApis/TokenApi.ts @@ -145,7 +145,7 @@ export class TokenApi extends GalaChainBaseApi { return this.submit, FetchTokenClassesDto>({ method: "FetchTokenClassesWithSupply", payload: dto, - sign: true, + sign: false, url: this.chainCodeUrl, requestConstructor: FetchTokenClassesDto, responseConstructor: TokenClass diff --git a/chain-connect/src/customClients/BrowserConnectClient.spec.ts b/chain-connect/src/customClients/BrowserConnectClient.spec.ts new file mode 100644 index 0000000000..15973dd874 --- /dev/null +++ b/chain-connect/src/customClients/BrowserConnectClient.spec.ts @@ -0,0 +1,132 @@ +/* + * 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 { signatures } from "@gala-chain/api"; +import { Eip1193Provider, TypedDataField, Wallet, getBytes } from "ethers"; + +import { SigningType } from "../types"; +import { generateEIP712Types } from "../utils"; +import { BrowserConnectClient } from "./BrowserConnectClient"; + +const firstKey = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const secondKey = "0x311e3750b1b698e70a2b37fd08b68fdcb389f955faea163f6ffa5be65cd0c251"; + +/** EIP-1193 provider backed by a local ethers wallet. */ +function walletProvider(wallet: Wallet): Eip1193Provider & { on: jest.Mock; removeListener: jest.Mock } { + return { + on: jest.fn(), + removeListener: jest.fn(), + async request({ method, params }: { method: string; params?: Array }) { + switch (method) { + case "eth_requestAccounts": + case "eth_accounts": + return [wallet.address]; + case "eth_chainId": + return "0x1"; + case "personal_sign": + return wallet.signMessage(getBytes(params?.[0] as string)); + case "eth_signTypedData_v4": { + const typedData = JSON.parse(params?.[1] as string); + const { EIP712Domain: _domainType, ...types } = typedData.types as Record; + return wallet.signTypedData(typedData.domain, types, typedData.message); + } + default: + throw new Error(`Method not mocked: ${method}`); + } + } + }; +} + +function recoveredAddress(signature: string, dto: object): string { + return "0x" + signatures.getEthAddress(signatures.recoverPublicKey(signature, dto)); +} + +const multisigPayload = { + quantity: "1", + tokenInstances: [{ collection: "GALA", instance: "0" }], + signerAddress: "eth|63580d94c574ad78b121c267", + dtoOperation: "asset-channel_basic-asset_GalaChainToken:TransferToken", + dtoExpiresAt: 4000000000000, + uniqueKey: "multisig-1" +}; + +describe("BrowserConnectClient multisig", () => { + it("adds a typed-data signature that verifies against the first signer's types and domain", async () => { + // Given + const first = new Wallet(firstKey); + const second = new Wallet(secondKey); + const types = generateEIP712Types("TransferToken", multisigPayload); + const domain = { name: "GalaChainDev" }; + const firstSignature = await first.signTypedData(domain, types, multisigPayload); + const signedOnce = { + ...multisigPayload, + types, + domain, + prefix: "first-prefix", + signature: firstSignature + }; + const client = new BrowserConnectClient(walletProvider(second), { network: "STG" }); + await client.connect(); + + // When + const signed = await client.sign("TransferToken", signedOnce); + + // Then + expect(signed.signature).toBeUndefined(); + expect(signed.types).toEqual(types); + expect(signed.domain).toEqual(domain); + expect(signed.prefix).toBe("first-prefix"); + expect(signed.multisig?.[0]).toBe(firstSignature); + expect(recoveredAddress(signed.multisig?.[0] as string, signed)).toBe(first.address); + expect(recoveredAddress(signed.multisig?.[1] as string, signed)).toBe(second.address); + }); + + it("adds a personal signature when the existing signatures use personal sign", async () => { + // Given + const first = new Wallet(firstKey); + const second = new Wallet(secondKey); + const message = signatures.personalSignMessage(multisigPayload); + const prefix = signatures.ethereumPersonalSignPrefix(message); + const firstSignature = await first.signMessage(message); + const client = new BrowserConnectClient(walletProvider(second)); + await client.connect(); + + // When + const signed = await client.sign( + "TransferToken", + { ...multisigPayload, prefix, multisig: [firstSignature] }, + SigningType.SIGN_TYPED_DATA + ); + + // Then + expect("types" in signed).toBe(false); + expect(signed.prefix).toBe(prefix); + expect(recoveredAddress(signed.multisig?.[0] as string, signed)).toBe(first.address); + expect(recoveredAddress(signed.multisig?.[1] as string, signed)).toBe(second.address); + }); + + it("rejects a payload whose signatures a wallet cannot match", async () => { + // Given + const client = new BrowserConnectClient(walletProvider(new Wallet(secondKey))); + await client.connect(); + + // When & Then + await expect( + client.sign("TransferToken", { ...multisigPayload, signature: "0xexisting" }) + ).rejects.toThrow("Cannot add a wallet signature"); + await expect( + client.sign("TransferToken", { ...multisigPayload, prefix: "0x1234", signature: "0xexisting" }) + ).rejects.toThrow("Cannot add a wallet signature"); + }); +}); diff --git a/chain-connect/src/customClients/BrowserConnectClient.ts b/chain-connect/src/customClients/BrowserConnectClient.ts index fde8a99765..4bf11c0dc5 100644 --- a/chain-connect/src/customClients/BrowserConnectClient.ts +++ b/chain-connect/src/customClients/BrowserConnectClient.ts @@ -13,7 +13,14 @@ * limitations under the License. */ import { configuredDtoOperation, signatures, withDtoOperation } from "@gala-chain/api"; -import { BrowserProvider, Eip1193Provider, getAddress } from "ethers"; +import { + BrowserProvider, + Eip1193Provider, + JsonRpcSigner, + TypedDataEncoder, + TypedDataField, + getAddress +} from "ethers"; import { GalaChainProviderOptions, WebSigner } from "../GalaChainClient"; import { ExtendedEip1193Provider } from "../helpers"; @@ -124,7 +131,8 @@ export class BrowserConnectClient extends WebSigner { * @template T - The type of the payload to sign * @param method - The method name for EIP-712 signing * @param payload - The data to sign - * @param signingType - The type of signing to use (defaults to signTypedData) + * @param signingType - The type of signing to use (defaults to signTypedData). When the payload + * is already signed, the new signature uses the same mode as the existing ones instead. * @returns Promise resolving to the signed payload with signature * @throws {Error} If no provider/account is connected or signing fails */ @@ -144,24 +152,31 @@ export class BrowserConnectClient extends WebSigner { const stamped = withDtoOperation(payload, configuredDtoOperation(this.operationParts(), method)); const record = stamped as T & Record; const existing = existingSignatures(record); + const signer = await this.provider.getSigner(); + if (existing.length > 0) { assertMultisigReady(record); + return (await this.appendSignature(signer, record, existing)) as T & { + prefix: string; + signature?: string; + multisig?: string[]; + }; } const message = omitVerificationMetadata(record); - const signer = await this.provider.getSigner(); if (signingType === SigningType.SIGN_TYPED_DATA) { + const body = omitTypedData(message); const domain = this.eip712Domain(); - const types = generateEIP712Types(method, message); - const prefix = this.calculatePersonalSignPrefix(message); - const prefixedPayload = { ...message, prefix }; + const types = generateEIP712Types(method, body); + const prefix = this.calculatePersonalSignPrefix(body); + const prefixedPayload = { ...body, prefix }; const signature = await signer.signTypedData( domain, types, valueForTypedDataSigning(types, method, prefixedPayload) ); - return withSignature(prefixedPayload, existing, signature, { types, domain }) as T & { + return { ...prefixedPayload, types, domain, signature } as T & { prefix: string; signature?: string; multisig?: string[]; @@ -171,7 +186,7 @@ export class BrowserConnectClient extends WebSigner { const personalMessage = signatures.personalSignMessage(body); const prefix = this.calculatePersonalSignPrefix(body); const signature = await signer.signMessage(personalMessage); - return withSignature({ ...body, prefix }, existing, signature) as T & { + return { ...body, prefix, signature } as T & { prefix: string; signature?: string; multisig?: string[]; @@ -183,6 +198,55 @@ export class BrowserConnectClient extends WebSigner { throw new Error((error as Error).message); } } + + /** + * Adds a signature to a payload that other signers already signed. Every signature + * is checked against the same types, domain and prefix, so they are reused as they are. + */ + private async appendSignature( + signer: JsonRpcSigner, + record: Record, + existing: string[] + ): Promise> { + const { types, domain, prefix } = record; + const body = omitTypedData(omitVerificationMetadata(record)); + + if (isRecord(types) && isRecord(domain)) { + const { EIP712Domain: _eip712Domain, ...messageTypes } = types as Record; + const primaryType = TypedDataEncoder.getPrimaryType(messageTypes); + const signature = await signer.signTypedData( + domain, + messageTypes, + valueForTypedDataSigning(messageTypes, primaryType, body) + ); + return { + ...body, + ...(prefix !== undefined ? { prefix } : {}), + types, + domain, + multisig: [...existing, signature] + }; + } + + if (types === undefined && domain === undefined && typeof prefix === "string") { + if (prefix !== this.calculatePersonalSignPrefix(body)) { + throw new Error( + "Cannot add a wallet signature: the existing signatures use a prefix a wallet personal sign does not produce" + ); + } + const signature = await signer.signMessage(signatures.personalSignMessage(body)); + return { ...body, prefix, multisig: [...existing, signature] }; + } + + throw new Error( + "Cannot add a wallet signature: the payload must carry the EIP-712 types and domain, " + + "or the personal sign prefix, used by the existing signatures" + ); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function existingSignatures(payload: object): string[] { @@ -246,15 +310,3 @@ function omitVerificationMetadata( }; return message; } - -function withSignature( - payload: T, - existing: string[], - signature: string, - typedData?: { types: unknown; domain: unknown } -): T & { signature?: string; multisig?: string[] } { - if (existing.length > 0) { - return { ...payload, ...typedData, multisig: [...existing, signature] }; - } - return { ...payload, ...typedData, signature }; -} diff --git a/chain-connect/src/eip712DomainName.spec.ts b/chain-connect/src/eip712DomainName.spec.ts index 6effa6b481..045c0f7d21 100644 --- a/chain-connect/src/eip712DomainName.spec.ts +++ b/chain-connect/src/eip712DomainName.spec.ts @@ -15,17 +15,23 @@ import { eip712DomainName } from "./eip712DomainName"; describe("eip712DomainName", () => { - it("stays GalaChain when no deployment is selected", () => { - expect(eip712DomainName()).toBe("GalaChain"); - expect(eip712DomainName({ network: "MNT" })).toBe("GalaChain"); + it("uses the dev name when no network is selected", () => { + // When & Then + expect(eip712DomainName()).toBe("GalaChainDev"); + expect(eip712DomainName({})).toBe("GalaChainDev"); }); - it("uses the stage and dev names", () => { + it("uses the name for each network", () => { + // When & Then + expect(eip712DomainName({ network: "MNT" })).toBe("GalaChain"); expect(eip712DomainName({ network: "STG" })).toBe("GalaChainStage"); expect(eip712DomainName({ network: "DEV" })).toBe("GalaChainDev"); + expect(eip712DomainName({ network: "OTHER" })).toBe("GalaChainDev"); }); it("lets an explicit name override the network", () => { + // When & Then expect(eip712DomainName({ network: "STG", domainName: "GalaChain" })).toBe("GalaChain"); + expect(eip712DomainName({ domainName: "GalaChain" })).toBe("GalaChain"); }); }); diff --git a/chain-connect/src/eip712DomainName.ts b/chain-connect/src/eip712DomainName.ts index 2d0db310c3..b8dc40c913 100644 --- a/chain-connect/src/eip712DomainName.ts +++ b/chain-connect/src/eip712DomainName.ts @@ -14,8 +14,9 @@ */ /** - * Domain name for an EIP-712 signature. - * An explicit name wins. Otherwise STG and DEV select the deployment name, and anything else stays GalaChain. + * Domain name for an EIP-712 signature. Matches the name the chaincode expects. + * An explicit name wins. Otherwise MNT is GalaChain, STG is GalaChainStage, and + * anything else (including no network) is GalaChainDev. * chainId is not part of this domain. */ export function eip712DomainName(options?: { domainName?: string; network?: string }): string { @@ -23,11 +24,11 @@ export function eip712DomainName(options?: { domainName?: string; network?: stri if (explicit) { return explicit; } + if (options?.network === "MNT") { + return "GalaChain"; + } if (options?.network === "STG") { return "GalaChainStage"; } - if (options?.network === "DEV") { - return "GalaChainDev"; - } - return "GalaChain"; + return "GalaChainDev"; } diff --git a/chain-connect/src/fetchWithTimeout.spec.ts b/chain-connect/src/fetchWithTimeout.spec.ts index 6d230abea6..0643dfc895 100644 --- a/chain-connect/src/fetchWithTimeout.spec.ts +++ b/chain-connect/src/fetchWithTimeout.spec.ts @@ -12,8 +12,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { TextDecoder as NodeTextDecoder } from "util"; + import { GalaChainProvider } from "./GalaChainClient"; -import { DEFAULT_REQUEST_TIMEOUT_MS, fetchWithTimeout } from "./fetchWithTimeout"; +import { DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_REQUEST_TIMEOUT_MS, fetchWithTimeout } from "./fetchWithTimeout"; class TestProvider extends GalaChainProvider { public async sign(): Promise { @@ -24,6 +26,11 @@ class TestProvider extends GalaChainProvider { describe("fetchWithTimeout", () => { const originalFetch = global.fetch; + beforeAll(() => { + // jsdom does not provide TextDecoder, which browsers and Node do. + Object.assign(global, { TextDecoder: global.TextDecoder ?? NodeTextDecoder }); + }); + afterEach(() => { global.fetch = originalFetch; jest.useRealTimers(); @@ -75,4 +82,139 @@ describe("fetchWithTimeout", () => { await jest.advanceTimersByTimeAsync(5_000); await assertion; }); + + it("times out when the body stalls after the headers arrive", async () => { + // Given + jest.useFakeTimers(); + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + headers: { get: () => null }, + text: () => new Promise(() => undefined) + } as unknown as Response) + ) as typeof fetch; + const client = new TestProvider({ timeoutMs: 5_000 }); + + // When + const pending = client.submit({ url: "https://example.test", method: "Ping", payload: {}, sign: false }); + const assertion = expect(pending).rejects.toThrow("Request timed out after 5000ms"); + await jest.advanceTimersByTimeAsync(5_000); + + // Then + await assertion; + }); + + it("times out when a streamed body stops sending", async () => { + // Given + jest.useFakeTimers(); + const stalled = { + getReader: () => ({ + read: () => new Promise(() => undefined), + cancel: () => Promise.resolve() + }) + }; + global.fetch = jest.fn(() => + Promise.resolve({ ok: true, headers: { get: () => null }, body: stalled } as unknown as Response) + ) as typeof fetch; + + // When + const pending = fetchWithTimeout("https://example.test", { method: "POST" }, { timeoutMs: 1_000 }); + const assertion = expect(pending).rejects.toThrow("Request timed out after 1000ms"); + await jest.advanceTimersByTimeAsync(1_000); + + // Then + await assertion; + }); + + it("rejects a body larger than the limit", async () => { + // Given + const chunks = [new Uint8Array(600), new Uint8Array(600)]; + const cancel = jest.fn(() => Promise.resolve()); + const streamed = { + getReader: () => ({ + read: () => Promise.resolve(chunks.length ? { done: false, value: chunks.shift() } : { done: true }), + cancel + }) + }; + global.fetch = jest.fn(() => + Promise.resolve({ ok: true, headers: { get: () => null }, body: streamed } as unknown as Response) + ) as typeof fetch; + + // When + const pending = fetchWithTimeout("https://example.test", { method: "POST" }, { maxResponseBytes: 1_000 }); + + // Then + await expect(pending).rejects.toThrow("Response exceeds 1000 bytes"); + expect(cancel).toHaveBeenCalled(); + }); + + it("rejects a declared content-length above the default limit", async () => { + // Given + const text = jest.fn(); + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + headers: { + get: (key: string) => (key === "content-length" ? `${DEFAULT_MAX_RESPONSE_BYTES + 1}` : null) + }, + text + } as unknown as Response) + ) as typeof fetch; + + // When + const pending = fetchWithTimeout("https://example.test", { method: "POST" }); + + // Then + await expect(pending).rejects.toThrow(`Response exceeds ${DEFAULT_MAX_RESPONSE_BYTES} bytes`); + expect(text).not.toHaveBeenCalled(); + }); + + it("returns the body text read within the deadline", async () => { + // Given + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + headers: { get: () => null }, + text: () => Promise.resolve('{"a":1}') + } as unknown as Response) + ) as typeof fetch; + + // When + const { body } = await fetchWithTimeout("https://example.test", { method: "POST" }); + + // Then + expect(body).toBe('{"a":1}'); + }); + + it("cancels the request when the caller signal aborts", async () => { + // Given + let fetchSignal: AbortSignal | undefined; + global.fetch = jest.fn((_url: string, init?: RequestInit) => { + fetchSignal = init?.signal ?? undefined; + return new Promise(() => undefined); + }) as typeof fetch; + const caller = new AbortController(); + + // When + const pending = fetchWithTimeout("https://example.test", { method: "POST" }, { signal: caller.signal }); + caller.abort(); + + // Then + await expect(pending).rejects.toThrow("Request was aborted"); + expect(fetchSignal?.aborted).toBe(true); + }); + + it("does not send a request when the caller signal is already aborted", async () => { + // Given + global.fetch = jest.fn() as unknown as typeof fetch; + const caller = new AbortController(); + caller.abort(); + + // When + const pending = fetchWithTimeout("https://example.test", { method: "POST" }, { signal: caller.signal }); + + // Then + await expect(pending).rejects.toThrow("Request was aborted"); + expect(global.fetch).not.toHaveBeenCalled(); + }); }); diff --git a/chain-connect/src/fetchWithTimeout.ts b/chain-connect/src/fetchWithTimeout.ts index f5fa65aea3..beffe112c3 100644 --- a/chain-connect/src/fetchWithTimeout.ts +++ b/chain-connect/src/fetchWithTimeout.ts @@ -14,23 +14,120 @@ */ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +export const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; +export interface FetchWithTimeoutOptions { + /** Deadline for the whole request, including reading the body. */ + timeoutMs?: number; + /** Caller signal that cancels the request. */ + signal?: AbortSignal; + /** Largest response body accepted, in bytes. */ + maxResponseBytes?: number; +} + +export interface FetchedResponse { + response: Response; + /** Response body as text, read within the same deadline as the request. */ + body: string; +} + +/** + * Sends a request and reads its body under one deadline and size limit. + * The response body is returned as text, so callers do not read the stream again. + */ export async function fetchWithTimeout( url: string, init: RequestInit, - timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS -): Promise { + options: FetchWithTimeoutOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + let abortError: Error | undefined; + let rejectAborted: (error: Error) => void = () => undefined; + const aborted = new Promise((_resolve, reject) => { + rejectAborted = reject; + }); + // Avoid an unhandled rejection when nothing is waiting on it. + aborted.catch(() => undefined); + + const abort = (error: Error) => { + if (abortError) { + return; + } + abortError = error; + controller.abort(); + rejectAborted(error); + }; + + const timer = setTimeout(() => abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs); + const onCallerAbort = () => abort(new Error("Request was aborted")); + if (options.signal?.aborted) { + onCallerAbort(); + } else { + options.signal?.addEventListener("abort", onCallerAbort); + } try { - return await fetch(url, { ...init, signal: controller.signal }); + if (abortError) { + throw abortError; + } + const response = await Promise.race([fetch(url, { ...init, signal: controller.signal }), aborted]); + const body = await Promise.race([readBody(response, maxBytes), aborted]); + return { response, body }; } catch (error) { - if (controller.signal.aborted) { - throw new Error(`Request timed out after ${timeoutMs}ms`); + if (abortError) { + throw abortError; } throw error; } finally { clearTimeout(timer); + options.signal?.removeEventListener("abort", onCallerAbort); + } +} + +function tooLarge(maxBytes: number): Error { + return new Error(`Response exceeds ${maxBytes} bytes`); +} + +function utf8ByteLength(text: string): number { + let bytes = 0; + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return bytes; +} + +async function readBody(response: Response, maxBytes: number): Promise { + const declared = Number(response.headers?.get("content-length")); + if (Number.isFinite(declared) && declared > maxBytes) { + throw tooLarge(maxBytes); + } + + const reader = response.body?.getReader(); + if (!reader) { + const text = await response.text(); + if (utf8ByteLength(text) > maxBytes) { + throw tooLarge(maxBytes); + } + return text; + } + + const decoder = new TextDecoder(); + let received = 0; + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel().catch(() => undefined); + throw tooLarge(maxBytes); + } + text += decoder.decode(value, { stream: true }); } + return text + decoder.decode(); } diff --git a/chain-connect/src/utils/CommonUtils.spec.ts b/chain-connect/src/utils/CommonUtils.spec.ts index eed2b527d1..8db2625e0e 100644 --- a/chain-connect/src/utils/CommonUtils.spec.ts +++ b/chain-connect/src/utils/CommonUtils.spec.ts @@ -12,13 +12,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LockTokenDto, LockTokensDto, TokenInstanceKey, createValidSubmitDTO } from "@gala-chain/api"; +import { + LockTokenDto, + LockTokensDto, + TokenInstanceKey, + createValidSubmitDTO, + signatures +} from "@gala-chain/api"; import BigNumber from "bignumber.js"; import { instanceToPlain, plainToInstance } from "class-transformer"; import { ethers } from "ethers"; import { SigningClient } from "../customClients/SigningClient"; -import { galaChainToEthereumAddress, generateEIP712Types, valueForTypedDataSigning } from "./CommonUtils"; +import { + ethereumToGalaChainAddress, + galaChainToEthereumAddress, + generateEIP712Types, + valueForTypedDataSigning +} from "./CommonUtils"; describe("EIP-712 Signing", () => { it("should correctly generate EIP-712 types and values and sign the data for single types", async () => { @@ -281,4 +292,87 @@ describe("EIP-712 Signing", () => { ]) ); }); + + it("includes fields that only later array elements have", () => { + // Given + const params = { + tokenInstances: [ + { quantity: "1", tokenInstanceKey: { collection: "GALA", instance: "0" } }, + { + quantity: "2", + owner: "eth|abc", + tokenInstanceKey: { collection: "GALA", instance: "1", type: "none" } + } + ] + }; + + // When + const types = generateEIP712Types("LockTokens", params); + + // Then + expect(types.tokenInstances).toEqual([ + { name: "quantity", type: "string" }, + { name: "tokenInstanceKey", type: "tokenInstanceKey" }, + { name: "owner", type: "string" } + ]); + expect(types.tokenInstanceKey).toEqual([ + { name: "collection", type: "string" }, + { name: "instance", type: "string" }, + { name: "type", type: "string" } + ]); + }); + + it("allows a repeated nested name with the same fields and signs it", async () => { + // Given + const tokenInstance = (instance: string) => ({ collection: "GALA", category: "Unit", instance }); + const params = { + offered: [{ quantity: "1", tokenInstance: tokenInstance("0") }], + wanted: [{ quantity: "2", tokenInstance: tokenInstance("1") }], + uniqueKey: "swap-1" + }; + const wallet = new ethers.Wallet("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + const domain = { name: "GalaChain" }; + + // When + const types = generateEIP712Types("RequestTokenSwap", params); + const signature = await wallet.signTypedData(domain, types, params); + + // Then + expect(types.RequestTokenSwap).toEqual([ + { name: "offered", type: "offered[]" }, + { name: "wanted", type: "wanted[]" }, + { name: "uniqueKey", type: "string" } + ]); + expect(types.tokenInstance).toEqual([ + { name: "collection", type: "string" }, + { name: "category", type: "string" }, + { name: "instance", type: "string" } + ]); + const recovered = signatures.recoverPublicKey(signature, { ...params, types, domain }); + expect("0x" + signatures.getEthAddress(recovered)).toBe(wallet.address); + }); + + it("rejects a repeated nested name with different fields", () => { + // Given + const params = { + offered: { tokenInstance: { collection: "GALA" } }, + wanted: { tokenInstance: { collection: "GALA", instance: "1" } } + }; + + // When & Then + expect(() => generateEIP712Types("RequestTokenSwap", params)).toThrow( + "Name collisions not yet supported" + ); + }); + + it("replaces only a leading 0x when converting to a GalaChain address", () => { + // When & Then + expect(ethereumToGalaChainAddress("0x8ba1f109551bD432803012645Ac136ddd64DBA72")).toBe( + "eth|8ba1f109551bD432803012645Ac136ddd64DBA72" + ); + expect(ethereumToGalaChainAddress("8b0x1f109551bD432803012645Ac136ddd64DBA72")).toBe( + "8b0x1f109551bD432803012645Ac136ddd64DBA72" + ); + expect(ethereumToGalaChainAddress("")).toBe(""); + }); }); diff --git a/chain-connect/src/utils/CommonUtils.ts b/chain-connect/src/utils/CommonUtils.ts index 085b2b992b..52f5d2d24d 100644 --- a/chain-connect/src/utils/CommonUtils.ts +++ b/chain-connect/src/utils/CommonUtils.ts @@ -50,10 +50,10 @@ function scalarEip712Type(value: unknown): string { } } -function numericArrayType(values: number[]): string { +function numericArrayType(values: unknown[]): string { let type = "uint256"; for (const value of values) { - const valueType = numericEip712Type(value); + const valueType = numericEip712Type(value as number); if (valueType === "string") { return "string"; } @@ -64,10 +64,20 @@ function numericArrayType(values: number[]): string { return type; } +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sameFields(a: EIP712Field[], b: EIP712Field[]): boolean { + return a.length === b.length && a.every((field, i) => field.name === b[i].name && field.type === b[i].type); +} + /** * Generates EIP-712 type definitions from a given object structure. * Empty arrays stay encodable without reading a missing element. Whole numbers * that fit in uint256 or int256 use those types; other numbers are strings. + * Elements of an object array share one struct type with the fields of all + * elements. A nested name may repeat when its fields are the same. * @template T - The type of the object to generate types for * @param typeName - The name of the primary type * @param params - The object to analyze for type generation @@ -75,63 +85,77 @@ function numericArrayType(values: number[]): string { */ export function generateEIP712Types(typeName: string, params: T): EIP712Types { const types: EIP712Types = Object.create(null); - types[typeName] = []; - - function addField( - name: string, - fieldValue: unknown, - parentTypeName: string, - onlyGetType = false - ): string | undefined { - if (Array.isArray(fieldValue)) { - const elementType = arrayElementType(name, fieldValue, parentTypeName); - const arrayType = `${elementType ?? name}[]`; - if (!onlyGetType) { - types[parentTypeName].push({ name, type: arrayType }); - } - return arrayType; - } - if (typeof fieldValue === "object" && fieldValue !== null) { - if (hasOwn(types, name)) { - throw new Error("Name collisions not yet supported"); - } - types[name] = []; - Object.entries(fieldValue).forEach(([key, value]) => { - addField(key, value, name); - }); - if (!onlyGetType) { - types[parentTypeName].push({ name, type: name }); + function registerStruct(name: string, fields: EIP712Field[]): string { + if (hasOwn(types, name)) { + if (!sameFields(types[name], fields)) { + throw new Error(`Name collisions not yet supported: ${name} has different fields`); } return name; } + types[name] = fields; + return name; + } - const eipType = scalarEip712Type(fieldValue); - if (!onlyGetType) { - types[parentTypeName].push({ name, type: eipType }); + // One struct type for a list of objects: fields of all of them, in first-seen order. + function structType(name: string, objects: Record[]): string { + const names: string[] = []; + for (const object of objects) { + for (const key of Object.keys(object)) { + if (!names.includes(key)) { + names.push(key); + } + } } - return eipType; + const fields = names.map((key) => { + const values = objects.filter((object) => hasOwn(object, key)).map((object) => object[key]); + return { name: key, type: valuesType(key, values) }; + }); + return registerStruct(name, fields); } - function arrayElementType(name: string, values: unknown[], parentTypeName: string): string | undefined { - if (values.length === 0) { - return "string"; + // A single type that encodes every value given for the same field. + function valuesType(name: string, values: unknown[]): string { + if (values.every(isPlainObject)) { + return structType(name, values); + } + if (values.every(Array.isArray)) { + return `${arrayElementType(name, values.flat())}[]`; } if (values.every((value) => typeof value === "number")) { - return numericArrayType(values as number[]); + return numericArrayType(values); } - return addField(name, values[0], parentTypeName, true); + const fieldTypes = new Set(values.map(scalarEip712Type)); + if (fieldTypes.size === 1) { + return [...fieldTypes][0]; + } + // Numbers declared as strings are signed as strings. + if (values.every((value) => typeof value === "string" || typeof value === "number")) { + return "string"; + } + throw new Error(`Field ${name} has values of different types`); } - const excludedFromSchema = new Set(["signature", "multisig", "trace", "prefix"]); - Object.entries(params as Record).forEach(([key, value]) => { - if (excludedFromSchema.has(key)) { - return; + function arrayElementType(name: string, values: unknown[]): string { + if (values.length === 0) { + return "string"; } - addField(key, value, typeName); - }); + return valuesType(name, values); + } - return types; + const excludedFromSchema = new Set(["signature", "multisig", "trace", "prefix"]); + const message = Object.fromEntries( + Object.entries(params as Record).filter(([key]) => !excludedFromSchema.has(key)) + ); + structType(typeName, [message]); + + // Keep the primary type first, as before. + const ordered: EIP712Types = Object.create(null); + ordered[typeName] = types[typeName]; + for (const name of Object.keys(types)) { + ordered[name] = types[name]; + } + return ordered; } function coerceTypedValue(types: EIP712Types, type: string, value: unknown): unknown { @@ -192,5 +216,8 @@ export function galaChainToEthereumAddress(galaAddress: string) { * @returns The equivalent GalaChain address */ export function ethereumToGalaChainAddress(ethereumAddress: string) { - return ethereumAddress?.replace("0x", "eth|") ?? ""; + if (!ethereumAddress) { + return ""; + } + return ethereumAddress.startsWith("0x") ? `eth|${ethereumAddress.slice(2)}` : ethereumAddress; } diff --git a/chain-connect/src/utils/WalletUtils.spec.ts b/chain-connect/src/utils/WalletUtils.spec.ts index 493a0fc451..394608e94d 100644 --- a/chain-connect/src/utils/WalletUtils.spec.ts +++ b/chain-connect/src/utils/WalletUtils.spec.ts @@ -19,7 +19,7 @@ function jsonResponse(status = 200): Response { ok: status >= 200 && status < 300, status, headers: { get: () => "2" }, - json: async () => ({ Status: 1, Data: {} }) + text: async () => JSON.stringify({ Status: 1, Data: {} }) } as unknown as Response; } diff --git a/chain-connect/src/utils/WalletUtils.ts b/chain-connect/src/utils/WalletUtils.ts index a627b498f5..67b1fca622 100644 --- a/chain-connect/src/utils/WalletUtils.ts +++ b/chain-connect/src/utils/WalletUtils.ts @@ -50,7 +50,7 @@ export class WalletUtils { * @returns {Promise} - A promise that resolves to a success message after registration succeeds. */ static async registerWallet(uri: string, walletPublicKey: string): Promise { - const response = await fetchWithTimeout(uri, { + const { response } = await fetchWithTimeout(uri, { method: "POST", headers: { "Content-Type": "application/json" diff --git a/chain-connect/test/test-utils.ts b/chain-connect/test/test-utils.ts index 0057b82fac..cc747c7a80 100644 --- a/chain-connect/test/test-utils.ts +++ b/chain-connect/test/test-utils.ts @@ -17,6 +17,7 @@ export const mockFetch = (body: Record, headers?: Record Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), headers: { get: (key: string) => headers?.[key] } From 8a837e010af69ed3dab89a8e59906c8ad3c41fc0 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 29 Oct 2026 18:22:31 +0100 Subject: [PATCH 4/7] Fix: Validate client responses, keys and configuration A response without a valid status is an error, not a success. Private keys are accepted as hex, 0x-hex or base64. The connection profile is checked before the Fabric client uses it. REST contracts are unique per chaincode, and a repeated path fragment on a channel is rejected. Co-Authored-By: Claude Opus 5.5 --- chain-client/src/generic/keys.spec.ts | 49 ++++++ chain-client/src/generic/keys.ts | 21 ++- chain-client/src/hf/HFClientBuilder.ts | 10 +- .../src/hf/validateConnectionProfile.spec.ts | 144 ++++++++++++++++++ .../src/hf/validateConnectionProfile.ts | 101 ++++++++++++ .../src/rest-api/loadRestApiConfig.spec.ts | 54 ++++++- .../src/rest-api/loadRestApiConfig.ts | 28 +++- chain-client/src/wire/response.spec.ts | 91 +++++++++++ chain-client/src/wire/response.ts | 23 ++- chain-client/src/wire/sign.ts | 3 +- 10 files changed, 505 insertions(+), 19 deletions(-) create mode 100644 chain-client/src/generic/keys.spec.ts create mode 100644 chain-client/src/hf/validateConnectionProfile.spec.ts create mode 100644 chain-client/src/hf/validateConnectionProfile.ts create mode 100644 chain-client/src/wire/response.spec.ts diff --git a/chain-client/src/generic/keys.spec.ts b/chain-client/src/generic/keys.spec.ts new file mode 100644 index 0000000000..7234cd7bee --- /dev/null +++ b/chain-client/src/generic/keys.spec.ts @@ -0,0 +1,49 @@ +/* + * 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 { ChainUser } from "./ChainUser"; +import { getPublicKey, normalizePrivateKey } from "./keys"; + +// private key with a leading zero byte, so that its hex form can be written with 63 chars +const privateKeyHex = "0f1e2d3c4b5a69788796a5b4c3d2e1f00112233445566778899aabbccddeeff0"; +const expectedPublicKey = + "043284bae4a1b693dfbe8ac67400e4891f3b53681c5159a315561051f3d2c571" + + "39809a67959a613c00c0e099cbe961b04faff21a6a970df2c0fc2a77951a6e94de"; + +describe("keys", () => { + it.each([ + ["hex", privateKeyHex], + ["0x-prefixed hex", `0x${privateKeyHex}`], + ["hex without leading zero", privateKeyHex.slice(1)], + ["base64", Buffer.from(privateKeyHex, "hex").toString("base64")] + ])("should derive the same public key from %s private key", (_, privateKey) => { + // When + const publicKey = getPublicKey(privateKey); + const user = new ChainUser({ privateKey }); + + // Then + expect(publicKey).toEqual(expectedPublicKey); + expect(user.publicKey).toEqual(expectedPublicKey); + }); + + it.each([ + ["empty", ""], + ["too short hex", "abcd"], + ["non-hex characters", "z".repeat(64)], + ["invalid base64", "!".repeat(44)] + ])("should reject %s private key", (_, privateKey) => { + // When & Then + expect(() => normalizePrivateKey(privateKey)).toThrow("Cannot normalize secp256k1 private key"); + }); +}); diff --git a/chain-client/src/generic/keys.ts b/chain-client/src/generic/keys.ts index 0996a1690b..4a4d98c387 100644 --- a/chain-client/src/generic/keys.ts +++ b/chain-client/src/generic/keys.ts @@ -28,8 +28,27 @@ export function genKeyPair(): { privateKey: string; publicKey: string } { }; } +// Accepts a secp256k1 private key as hex (with or without "0x", possibly missing +// a leading zero) or as base64, and returns the raw 32 key bytes. +export function normalizePrivateKey(privateKey: string): Buffer { + const input = privateKey.startsWith("0x") ? privateKey.slice(2) : privateKey; + + if (input.length >= 61 && input.length <= 64 && /^[0-9a-fA-F]+$/.test(input)) { + return Buffer.from(input.padStart(64, "0"), "hex"); + } + + if (input.length === 44 && /^[A-Za-z0-9+/]{43}=$/.test(input)) { + return Buffer.from(input, "base64"); + } + + throw new Error( + `Cannot normalize secp256k1 private key. Got string of length ${input.length}, ` + + `but expected 44 for base64 or up to 64 for hex encoding (excluding '0x').` + ); +} + export function getPublicKey(privateKey: string): string { - const key = Buffer.from(privateKey.replace(/^0x/, ""), "hex"); + const key = normalizePrivateKey(privateKey); return Buffer.from(secp256k1.publicKeyCreate(new Uint8Array(key), false)).toString("hex"); } diff --git a/chain-client/src/hf/HFClientBuilder.ts b/chain-client/src/hf/HFClientBuilder.ts index c6b81afec1..fd85876571 100644 --- a/chain-client/src/hf/HFClientBuilder.ts +++ b/chain-client/src/hf/HFClientBuilder.ts @@ -17,6 +17,7 @@ import { Contract, Gateway, Network } from "fabric-network"; import { ChainClientBuilder, ContractConfig } from "../generic"; import { CAClient } from "./CAClient"; import { HFClient } from "./HFClient"; +import { validateConnectionProfile } from "./validateConnectionProfile"; export class HFClientBuilder extends ChainClientBuilder { public readonly useServiceDiscovery: boolean; @@ -31,6 +32,8 @@ export class HFClientBuilder extends ChainClientBuilder { ) { super(); + validateConnectionProfile(connectionProfile, orgMsp); + this.caClient = new CAClient(orgMsp, adminId, adminSecret, connectionProfile); // if there are both orderers and channels in the connection profile, then we don't need to use service discovery @@ -38,12 +41,9 @@ export class HFClientBuilder extends ChainClientBuilder { isNonEmptyObject(connectionProfile.orderers) && isNonEmptyObject(connectionProfile.channels) ); - if (!isNonEmptyObject(connectionProfile.peers)) { - throw new Error("No peers found in connection profile"); - } - // if peer urls contain localhost, then asLocalhost = true - const peerUrls = Object.values(connectionProfile.peers ?? {}).map((peer) => peer?.url); + const peers = connectionProfile.peers as Record; + const peerUrls = Object.values(peers).map((peer) => peer.url); const localhostUrls = peerUrls.filter((url) => url.includes("//localhost:")); if (localhostUrls.length > 0 && localhostUrls.length !== peerUrls.length) { diff --git a/chain-client/src/hf/validateConnectionProfile.spec.ts b/chain-client/src/hf/validateConnectionProfile.spec.ts new file mode 100644 index 0000000000..df009377a7 --- /dev/null +++ b/chain-client/src/hf/validateConnectionProfile.spec.ts @@ -0,0 +1,144 @@ +/* + * 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 { HFClientBuilder } from "./HFClientBuilder"; +import { validateConnectionProfile } from "./validateConnectionProfile"; + +const orgMsp = "CuratorOrg"; + +// loosely typed so that tests can break arbitrary parts of the profile +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Profile = Record; + +function sampleProfile(): Profile { + return { + organizations: { + CuratorOrg: { + mspid: "CuratorOrg", + peers: ["peer0.curator.local"], + certificateAuthorities: ["ca.curator.local"] + } + }, + peers: { + "peer0.curator.local": { url: "grpc://localhost:7041" } + }, + certificateAuthorities: { + "ca.curator.local": { url: "http://localhost:7040", caName: "ca.curator.local" } + }, + channels: { + "product-channel": { peers: { "peer0.curator.local": { endorsingPeer: true } } } + } + }; +} + +describe("validateConnectionProfile", () => { + it("should accept a valid profile", () => { + // When & Then + expect(() => validateConnectionProfile(sampleProfile(), orgMsp)).not.toThrow(); + }); + + it("should accept a profile without channels", () => { + // Given + const profile = sampleProfile(); + delete profile.channels; + + // When & Then + expect(() => validateConnectionProfile(profile, orgMsp)).not.toThrow(); + }); + + it.each<[string, (p: Profile) => void, string]>([ + ["missing organizations", (p) => delete p.organizations, "missing .organizations"], + [ + "missing org", + (p) => delete p.organizations.CuratorOrg, + "missing organization .organizations.CuratorOrg" + ], + [ + "org without CAs", + (p) => (p.organizations.CuratorOrg.certificateAuthorities = []), + ".organizations.CuratorOrg.certificateAuthorities must be a non-empty array of names" + ], + ["missing peers", (p) => delete p.peers, "missing .peers"], + ["empty peers", (p) => (p.peers = {}), "missing .peers"], + [ + "peer without url", + (p) => (p.peers["peer0.curator.local"] = {}), + ".peers.peer0.curator.local.url must be a non-empty string" + ], + [ + "peer with non-string url", + (p) => (p.peers["peer0.curator.local"].url = 7041), + ".peers.peer0.curator.local.url must be a non-empty string" + ], + [ + "missing certificateAuthorities", + (p) => delete p.certificateAuthorities, + "missing .certificateAuthorities" + ], + [ + "CA without url", + (p) => delete p.certificateAuthorities["ca.curator.local"].url, + ".certificateAuthorities.ca.curator.local.url must be a non-empty string" + ], + [ + "org referencing unknown CA", + (p) => (p.organizations.CuratorOrg.certificateAuthorities = ["ca.other"]), + ".organizations.CuratorOrg.certificateAuthorities references unknown CA ca.other" + ], + ["channels not an object", (p) => (p.channels = []), ".channels must be an object"], + [ + "channel not an object", + (p) => (p.channels["product-channel"] = "x"), + ".channels.product-channel must be an object" + ], + [ + "channel referencing unknown peer", + (p) => (p.channels["product-channel"].peers = { "peer9.local": {} }), + ".channels.product-channel.peers references unknown peer peer9.local" + ] + ])("should reject profile with %s", (_, mutate, message) => { + // Given + const profile = sampleProfile(); + mutate(profile); + + // When & Then + expect(() => validateConnectionProfile(profile, orgMsp)).toThrow( + `Invalid connection profile: ${message}` + ); + }); +}); + +describe("HFClientBuilder", () => { + it("should fail with a configuration error when a peer has no url", () => { + // Given + const profile = sampleProfile(); + delete profile.peers["peer0.curator.local"].url; + + // When + const build = () => new HFClientBuilder(orgMsp, "admin", "adminpw", profile); + + // Then + expect(build).toThrow( + "Invalid connection profile: .peers.peer0.curator.local.url must be a non-empty string" + ); + }); + + it("should detect localhost peers", () => { + // When + const builder = new HFClientBuilder(orgMsp, "admin", "adminpw", sampleProfile()); + + // Then + expect(builder.asLocalhost).toBe(true); + }); +}); diff --git a/chain-client/src/hf/validateConnectionProfile.ts b/chain-client/src/hf/validateConnectionProfile.ts new file mode 100644 index 0000000000..99c2044ef0 --- /dev/null +++ b/chain-client/src/hf/validateConnectionProfile.ts @@ -0,0 +1,101 @@ +/* + * 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. + */ + +type Obj = Record; + +function isObject(value: unknown): value is Obj { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function fail(message: string): never { + throw new Error(`Invalid connection profile: ${message}`); +} + +/** + * Checks the parts of a Fabric connection profile the client relies on, so that + * a broken profile fails early with a message pointing at the bad entry. + */ +export function validateConnectionProfile(profile: unknown, orgMsp: string): void { + if (!isObject(profile)) { + fail("expected an object"); + } + + const organizations = profile.organizations; + if (!isObject(organizations)) { + fail("missing .organizations"); + } + + const org = organizations[orgMsp]; + if (!isObject(org)) { + fail(`missing organization .organizations.${orgMsp}`); + } + + const orgCAs = org.certificateAuthorities; + if (!Array.isArray(orgCAs) || orgCAs.length === 0 || !orgCAs.every(isNonEmptyString)) { + fail(`.organizations.${orgMsp}.certificateAuthorities must be a non-empty array of names`); + } + + const peers = profile.peers; + if (!isObject(peers) || Object.keys(peers).length === 0) { + fail("missing .peers"); + } + for (const [name, peer] of Object.entries(peers)) { + if (!isObject(peer) || !isNonEmptyString(peer.url)) { + fail(`.peers.${name}.url must be a non-empty string`); + } + } + + const certificateAuthorities = profile.certificateAuthorities; + if (!isObject(certificateAuthorities)) { + fail("missing .certificateAuthorities"); + } + for (const [name, ca] of Object.entries(certificateAuthorities)) { + if (!isObject(ca) || !isNonEmptyString(ca.url)) { + fail(`.certificateAuthorities.${name}.url must be a non-empty string`); + } + } + for (const name of orgCAs) { + if (!(name in certificateAuthorities)) { + fail(`.organizations.${orgMsp}.certificateAuthorities references unknown CA ${name}`); + } + } + + const channels = profile.channels; + if (channels === undefined) { + return; + } + if (!isObject(channels)) { + fail(".channels must be an object"); + } + for (const [name, channel] of Object.entries(channels)) { + if (!isObject(channel)) { + fail(`.channels.${name} must be an object`); + } + if (channel.peers !== undefined) { + if (!isObject(channel.peers)) { + fail(`.channels.${name}.peers must be an object`); + } + for (const peerName of Object.keys(channel.peers)) { + if (!(peerName in peers)) { + fail(`.channels.${name}.peers references unknown peer ${peerName}`); + } + } + } + } +} diff --git a/chain-client/src/rest-api/loadRestApiConfig.spec.ts b/chain-client/src/rest-api/loadRestApiConfig.spec.ts index 695503a90d..5065b9f59c 100644 --- a/chain-client/src/rest-api/loadRestApiConfig.spec.ts +++ b/chain-client/src/rest-api/loadRestApiConfig.spec.ts @@ -101,7 +101,59 @@ describe("client configuration", () => { config.channels[0].contracts.push({ ...config.channels[0].contracts[0] }); const file = write("rest.json", config); - expect(() => loadRestApiConfig(file)).toThrow("Duplicate contract GalaChainToken on channel product"); + expect(() => loadRestApiConfig(file)).toThrow( + "Duplicate contract GalaChainToken in chaincode assets on channel product" + ); + }); + + it("accepts the same contract name in different chaincodes on one channel", () => { + // Given + const config = validConfig(); + config.channels[0].contracts = [ + { chaincodeName: "assets", contractName: "PublicKeyContract", pathFragment: "assets-public-key" }, + { chaincodeName: "trading", contractName: "PublicKeyContract", pathFragment: "trading-public-key" } + ]; + const file = write("rest.json", config); + + // When + const loaded = loadRestApiConfig(file); + + // Then + expect(loaded.channels[0].contracts).toHaveLength(2); + }); + + it("rejects a duplicate contract pathFragment on one channel", () => { + // Given + const config = validConfig(); + config.channels[0].contracts.push({ + chaincodeName: "trading", + contractName: "PublicKeyContract", + pathFragment: "token" + }); + const file = write("rest.json", config); + + // When & Then + expect(() => loadRestApiConfig(file)).toThrow("Duplicate contract pathFragment token on channel product"); + }); + + it("accepts the same contract pathFragment on different channels", () => { + // Given + const config = validConfig(); + config.channels.push({ ...config.channels[0], channelName: "asset", pathFragment: "asset" }); + const file = write("rest.json", config); + + // When & Then + expect(loadRestApiConfig(file).channels).toHaveLength(2); + }); + + it("rejects a duplicate channel pathFragment", () => { + // Given + const config = validConfig(); + config.channels.push({ ...config.channels[0], channelName: "asset" }); + const file = write("rest.json", config); + + // When & Then + expect(() => loadRestApiConfig(file)).toThrow("Duplicate channel pathFragment product"); }); it("loads a usable REST configuration", () => { diff --git a/chain-client/src/rest-api/loadRestApiConfig.ts b/chain-client/src/rest-api/loadRestApiConfig.ts index c54bf7b51c..6f49a992ef 100644 --- a/chain-client/src/rest-api/loadRestApiConfig.ts +++ b/chain-client/src/rest-api/loadRestApiConfig.ts @@ -132,18 +132,38 @@ export function loadRestApiConfig(configPath: string): RestApiConfig { function assertUniqueNames(config: RestApiConfig): void { const channelNames = new Set(); + const channelPaths = new Set(); for (const channel of config.channels) { if (channelNames.has(channel.channelName)) { throw new Error(`Duplicate channel ${channel.channelName}`); } channelNames.add(channel.channelName); - const contractNames = new Set(); + if (channelPaths.has(channel.pathFragment)) { + throw new Error(`Duplicate channel pathFragment ${channel.pathFragment}`); + } + channelPaths.add(channel.pathFragment); + + // contracts are identified by (chaincode, contract) within a channel + const contractKeys = new Set(); + // the REST URL is built from the path fragment, so it has to be unique within a channel + const contractPaths = new Set(); for (const contract of channel.contracts) { - if (contractNames.has(contract.contractName)) { - throw new Error(`Duplicate contract ${contract.contractName} on channel ${channel.channelName}`); + const key = JSON.stringify([contract.chaincodeName, contract.contractName]); + if (contractKeys.has(key)) { + throw new Error( + `Duplicate contract ${contract.contractName} in chaincode ${contract.chaincodeName} ` + + `on channel ${channel.channelName}` + ); + } + contractKeys.add(key); + + if (contractPaths.has(contract.pathFragment)) { + throw new Error( + `Duplicate contract pathFragment ${contract.pathFragment} on channel ${channel.channelName}` + ); } - contractNames.add(contract.contractName); + contractPaths.add(contract.pathFragment); } } } diff --git a/chain-client/src/wire/response.spec.ts b/chain-client/src/wire/response.spec.ts new file mode 100644 index 0000000000..96662fbdf1 --- /dev/null +++ b/chain-client/src/wire/response.spec.ts @@ -0,0 +1,91 @@ +/* + * 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 { GalaChainResponse, GalaChainResponseType } from "./response"; + +class Item { + value?: string; +} + +describe("GalaChainResponse.deserialize", () => { + it("should deserialize success response", () => { + // When + const response = GalaChainResponse.deserialize(Item, '{"Status":1,"Data":{"value":"x"}}'); + + // Then + expect(response.Status).toEqual(GalaChainResponseType.Success); + expect(response.Data).toBeInstanceOf(Item); + expect(response.Data).toEqual({ value: "x" }); + }); + + it("should deserialize success response without data", () => { + // When + const response = GalaChainResponse.deserialize(undefined, { Status: 1 }); + + // Then + expect(response).toEqual({ Status: GalaChainResponseType.Success, Data: undefined }); + }); + + it("should deserialize error response", () => { + // When + const response = GalaChainResponse.deserialize(Item, { + Status: 0, + Message: "nope", + ErrorCode: 404, + ErrorKey: "NOT_FOUND" + }); + + // Then + expect(response).toEqual({ + Status: GalaChainResponseType.Error, + Message: "nope", + ErrorCode: 404, + ErrorKey: "NOT_FOUND", + ErrorPayload: undefined + }); + }); + + it("should fill defaults for error response with missing fields", () => { + // When + const response = GalaChainResponse.deserialize(Item, { Status: 0 }); + + // Then + expect(response).toEqual( + expect.objectContaining({ Status: GalaChainResponseType.Error, ErrorCode: 500, ErrorKey: "UNKNOWN" }) + ); + }); + + it.each<[string, string | Record]>([ + ["empty object", {}], + ["string status", { Status: "1", Data: { value: "x" } }], + ["unknown status", { Status: 2, Data: { value: "x" } }], + ["null", "null"], + ["array", "[]"], + ["number", "1"] + ])("should treat malformed response (%s) as error", (_, input) => { + // When + const response = GalaChainResponse.deserialize(Item, input); + + // Then + expect(GalaChainResponse.isSuccess(response)).toBe(false); + expect(response).toEqual( + expect.objectContaining({ + Status: GalaChainResponseType.Error, + ErrorCode: 500, + ErrorKey: "INVALID_CHAIN_RESPONSE" + }) + ); + expect(response.Data).toBeUndefined(); + }); +}); diff --git a/chain-client/src/wire/response.ts b/chain-client/src/wire/response.ts index 7012142956..2848c4235b 100644 --- a/chain-client/src/wire/response.ts +++ b/chain-client/src/wire/response.ts @@ -63,14 +63,23 @@ export const GalaChainResponse = { object: string | Record ): GalaChainResponse { const json = typeof object === "string" ? JSON.parse(object) : object; + if (json === null || typeof json !== "object" || Array.isArray(json)) { + return GalaChainResponse.Error("Chain response is not an object", 500, "INVALID_CHAIN_RESPONSE"); + } if (json.Status === GalaChainResponseType.Error) { - return { - Status: GalaChainResponseType.Error, - Message: json.Message, - ErrorCode: json.ErrorCode, - ErrorKey: json.ErrorKey, - ErrorPayload: json.ErrorPayload - }; + return GalaChainResponse.Error( + typeof json.Message === "string" ? json.Message : "Unknown chain error", + typeof json.ErrorCode === "number" ? json.ErrorCode : undefined, + typeof json.ErrorKey === "string" ? json.ErrorKey : undefined, + json.ErrorPayload + ); + } + if (json.Status !== GalaChainResponseType.Success) { + return GalaChainResponse.Error( + `Chain response has invalid status: ${JSON.stringify(json.Status)}`, + 500, + "INVALID_CHAIN_RESPONSE" + ); } const raw = json.Data; const data = diff --git a/chain-client/src/wire/sign.ts b/chain-client/src/wire/sign.ts index bae417da4a..19e0660e8c 100644 --- a/chain-client/src/wire/sign.ts +++ b/chain-client/src/wire/sign.ts @@ -16,6 +16,7 @@ import { randomBytes } from "crypto"; import { keccak256 } from "js-sha3"; import * as secp256k1 from "secp256k1"; +import { normalizePrivateKey } from "../generic/keys"; import { serialize } from "./serialize"; export function randomUniqueKey(): string { @@ -32,7 +33,7 @@ export function signPlain( delete payload.signature; delete payload.multisig; const hash = Buffer.from(keccak256.digest(Buffer.from(serialize(payload)))); - const key = Buffer.from(privateKey.replace(/^0x/, ""), "hex"); + const key = normalizePrivateKey(privateKey); const sig = secp256k1.ecdsaSign(new Uint8Array(hash), new Uint8Array(key)); const r = Buffer.from(sig.signature.slice(0, 32)).toString("hex"); const s = Buffer.from(sig.signature.slice(32, 64)).toString("hex"); From 3c2bde08852c63da5de1c9b9227427fac0c7f211 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 29 Oct 2026 18:22:31 +0100 Subject: [PATCH 5/7] Fix: Check CLI key arguments, signer identity, versions and network names Key arguments must be key files, and parse errors do not print the input. dto:sign checks a signer address or public key in the DTO against the signing key. Version checks follow semver precedence and use the running CLI version. network-up rejects channels that collide with built-in profiles, and api-config rejects a repeated path fragment. FetchTrees defaults its page size, and the unpaginated FetchBalances is marked deprecated. Co-Authored-By: Claude Opus 5.5 --- chain-cli/README.md | 49 ++++----- .../e2e/__snapshots__/api.spec.ts.snap | 5 +- .../src/apples/AppleContract.spec.ts | 26 ++++- .../chaincode-template/src/apples/dtos.ts | 5 +- .../src/apples/fetchTrees.ts | 2 +- .../src/token/GalaChainTokenContract.ts | 6 +- .../src/token/__snapshots__/api.spec.ts.snap | 5 +- chain-cli/src/commands/deploy/index.ts | 2 +- chain-cli/src/commands/dto-sign/index.spec.ts | 82 ++++++++++++--- chain-cli/src/commands/dto-sign/index.ts | 52 +++++++++- chain-cli/src/commands/info/index.ts | 2 +- chain-cli/src/commands/init/index.ts | 2 +- chain-cli/src/commands/logs/index.ts | 2 +- .../src/commands/network-up/index.spec.ts | 8 ++ chain-cli/src/commands/network-up/index.ts | 9 ++ chain-cli/src/commands/register/index.ts | 2 +- chain-cli/src/galachain-utils.keys.spec.ts | 47 ++++++++- chain-cli/src/galachain-utils.ts | 99 ++++++++++++++----- chain-cli/src/galachain-utils.version.spec.ts | 59 +++++++++-- chain-cli/src/signatures/eth.ts | 3 +- chain-cli/src/utils.spec.ts | 82 ++++++++++++++- chain-cli/src/utils.ts | 58 ++++++++--- 22 files changed, 505 insertions(+), 102 deletions(-) diff --git a/chain-cli/README.md b/chain-cli/README.md index ac6cccf17e..97a6b77460 100644 --- a/chain-cli/README.md +++ b/chain-cli/README.md @@ -7,14 +7,16 @@ CLI tool for Gala chaincode [![GitHub license](https://img.shields.io/github/license/oclif/hello-world)](https://github.com/oclif/hello-world/blob/main/LICENSE) -* [galachain](#galachain) -* [Usage](#usage) -* [Commands](#commands) + +- [galachain](#galachain) +- [Usage](#usage) +- [Commands](#commands) # Usage + ```sh-session $ npm install -g @gala-chain/cli $ galachain COMMAND @@ -26,26 +28,28 @@ USAGE $ galachain COMMAND ... ``` + # Commands -* [`galachain deploy IMAGETAG [DEVELOPERPRIVATEKEY]`](#galachain-deploy-imagetag-developerprivatekey) -* [`galachain dto-sign KEY DATA`](#galachain-dto-sign-key-data) -* [`galachain dto-verify KEY DATA`](#galachain-dto-verify-key-data) -* [`galachain dto:sign KEY DATA`](#galachain-dtosign-key-data) -* [`galachain dto:verify KEY DATA`](#galachain-dtoverify-key-data) -* [`galachain help [COMMAND]`](#galachain-help-command) -* [`galachain info [DEVELOPERPRIVATEKEY]`](#galachain-info-developerprivatekey) -* [`galachain init PATH`](#galachain-init-path) -* [`galachain keygen FILE`](#galachain-keygen-file) -* [`galachain logs`](#galachain-logs) -* [`galachain network-prune`](#galachain-network-prune) -* [`galachain network-up`](#galachain-network-up) -* [`galachain network:prune`](#galachain-networkprune) -* [`galachain network:up`](#galachain-networkup) -* [`galachain register [DEVELOPERPRIVATEKEY]`](#galachain-register-developerprivatekey) + +- [`galachain deploy IMAGETAG [DEVELOPERPRIVATEKEY]`](#galachain-deploy-imagetag-developerprivatekey) +- [`galachain dto-sign KEY DATA`](#galachain-dto-sign-key-data) +- [`galachain dto-verify KEY DATA`](#galachain-dto-verify-key-data) +- [`galachain dto:sign KEY DATA`](#galachain-dtosign-key-data) +- [`galachain dto:verify KEY DATA`](#galachain-dtoverify-key-data) +- [`galachain help [COMMAND]`](#galachain-help-command) +- [`galachain info [DEVELOPERPRIVATEKEY]`](#galachain-info-developerprivatekey) +- [`galachain init PATH`](#galachain-init-path) +- [`galachain keygen FILE`](#galachain-keygen-file) +- [`galachain logs`](#galachain-logs) +- [`galachain network-prune`](#galachain-network-prune) +- [`galachain network-up`](#galachain-network-up) +- [`galachain network:prune`](#galachain-networkprune) +- [`galachain network:up`](#galachain-networkup) +- [`galachain register [DEVELOPERPRIVATEKEY]`](#galachain-register-developerprivatekey) ## `galachain deploy IMAGETAG [DEVELOPERPRIVATEKEY]` @@ -58,7 +62,7 @@ USAGE ARGUMENTS IMAGETAG Image tag to deploy. It should follow the pattern imageName:version. - [DEVELOPERPRIVATEKEY] Developer's private key to sign the request. It could be a file or a string. If not provided as + [DEVELOPERPRIVATEKEY] Path to the developer's private key file used to sign the request. If not provided as an argument, the command will try to read the private key from the environment variable DEV_PRIVATE_KEY, or from the default location (~/.gc-keys//gc-dev-key), or will ask for it in a prompt. @@ -270,7 +274,7 @@ USAGE $ galachain info [DEVELOPERPRIVATEKEY] [--json] [--log-level debug|info|warn|error] [--mnt] ARGUMENTS - [DEVELOPERPRIVATEKEY] Developer's private key to sign the request. It could be a file or a string. If not provided as + [DEVELOPERPRIVATEKEY] Path to the developer's private key file used to sign the request. If not provided as an argument, the command will try to read the private key from the environment variable DEV_PRIVATE_KEY, or from the default location (~/.gc-keys//gc-dev-key), or will ask for it in a prompt. @@ -352,7 +356,7 @@ FLAGS -e, --endTime= [default: now] End time for logs. Accepts ISO8601 format or shorthand (e.g., now, 0). -f, --filter= Filter for logs (e.g., error, info, warn, debug). - -k, --developerPrivateKey= Private key to sign the data. It can be a file path or a key string. If not + -k, --developerPrivateKey= Path to the private key file used to sign the data. If not provided, the private key will be read from the environment variable DEV_PRIVATE_KEY. -l, --limit= Maximum number of log entries to fetch. @@ -515,7 +519,7 @@ USAGE $ galachain register [DEVELOPERPRIVATEKEY] [--json] [--log-level debug|info|warn|error] [--mnt] [--no-prompt] ARGUMENTS - [DEVELOPERPRIVATEKEY] Developer's private key to sign the request. It could be a file or a string. If not provided as + [DEVELOPERPRIVATEKEY] Path to the developer's private key file used to sign the request. If not provided as an argument, the command will try to read the private key from the environment variable DEV_PRIVATE_KEY, or from the default location (~/.gc-keys//gc-dev-key), or will ask for it in a prompt. @@ -539,4 +543,5 @@ EXAMPLES $ galachain register c0fb1924408d936fb7cd0c86695885df4f66861621b5c8660df3924c4d09dd79 ``` + diff --git a/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap b/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap index fd2b5c73c4..5c75f23811 100644 --- a/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap +++ b/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap @@ -5224,7 +5224,8 @@ The key is generated by the caller and should be unique for each DTO. You can us }, }, { - "description": " Transaction is read only (evaluate). Allowed roles: EVALUATE.", + "deprecated": true, + "description": "Deprecated: returns all matching balances without pagination. Use FetchBalancesWithTokenMetadata, which supports pagination, instead. Transaction is read only (evaluate). Allowed roles: EVALUATE.", "dtoSchema": { "description": "Contains parameters for fetching balances. Each parameter is optional.", "properties": { @@ -6043,7 +6044,7 @@ The key is generated by the caller and should be unique for each DTO. You can us "type": "string", }, "instance": { - "description": "Token instance. Optional.", + "description": "Token instance. Optional, but required if created is provided.", "minLength": 1, "type": "string", }, diff --git a/chain-cli/chaincode-template/src/apples/AppleContract.spec.ts b/chain-cli/chaincode-template/src/apples/AppleContract.spec.ts index 091e92ec00..1731fd0510 100644 --- a/chain-cli/chaincode-template/src/apples/AppleContract.spec.ts +++ b/chain-cli/chaincode-template/src/apples/AppleContract.spec.ts @@ -23,7 +23,7 @@ import { import { AppleTree, PickAppleDto, Variety } from "../apples"; import { AppleContract } from "./AppleContract"; -import { PlantAppleTreeDto } from "./dtos"; +import { FetchTreesDto, PlantAppleTreeDto } from "./dtos"; it("should allow to plant a tree", async () => { // Given @@ -87,3 +87,27 @@ it("should allow to pick apples", async () => { ) ); }); + +it("should fetch trees with the default page size when limit is not provided", async () => { + // Given + const tree = new AppleTree("client|some-user", Variety.GALA, 1, 0); + const user = users.random(); + const { contract, ctx } = fixture(AppleContract).registeredUsers(user).savedState(tree); + const query = jest.spyOn(ctx.stub, "getStateByPartialCompositeKeyWithPagination"); + const dto = new FetchTreesDto(tree.plantedBy) + .withOperation("asset-channel_basic-asset_AppleContract:FetchTrees") + .signed(user.privateKey); + + // When + const response = await contract.FetchTrees(ctx, dto); + + // Then + expect(response).toEqual(transactionSuccess(expect.anything())); + expect(response.Data?.trees).toEqual([tree]); + expect(query).toHaveBeenCalledWith( + AppleTree.INDEX_KEY, + [tree.plantedBy], + FetchTreesDto.DEFAULT_LIMIT, + undefined + ); +}); diff --git a/chain-cli/chaincode-template/src/apples/dtos.ts b/chain-cli/chaincode-template/src/apples/dtos.ts index ffd418c098..e76881d065 100644 --- a/chain-cli/chaincode-template/src/apples/dtos.ts +++ b/chain-cli/chaincode-template/src/apples/dtos.ts @@ -95,6 +95,9 @@ export class PickAppleDto extends SubmitCallDTO { } export class FetchTreesDto extends ChainCallDTO { + static readonly MAX_LIMIT = 1000; + static readonly DEFAULT_LIMIT = 1000; + @IsString() @ValidateIf((o) => o.plantedBy !== undefined || o.variety !== undefined) public readonly plantedBy?: string; @@ -114,7 +117,7 @@ export class FetchTreesDto extends ChainCallDTO { @IsOptional() @IsInt() @Min(1) - @Max(1000) + @Max(FetchTreesDto.MAX_LIMIT) public readonly limit?: number; constructor(plantedBy?: string, variety?: Variety, index?: number, bookmark?: string, limit?: number) { diff --git a/chain-cli/chaincode-template/src/apples/fetchTrees.ts b/chain-cli/chaincode-template/src/apples/fetchTrees.ts index 5f7aa6691c..c773ed8901 100644 --- a/chain-cli/chaincode-template/src/apples/fetchTrees.ts +++ b/chain-cli/chaincode-template/src/apples/fetchTrees.ts @@ -30,7 +30,7 @@ export async function fetchTrees(ctx: GalaChainContext, dto: FetchTreesDto): Pro keyParts, AppleTree, dto.bookmark, - dto.limit + dto.limit ?? FetchTreesDto.DEFAULT_LIMIT ); return new PagedTreesDto(results, metadata.bookmark); diff --git a/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts b/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts index dc1173a4bd..004882396a 100644 --- a/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts +++ b/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts @@ -405,7 +405,11 @@ export default class GalaChainTokenContract extends GalaContract { @GalaTransaction({ type: EVALUATE, in: FetchBalancesDto, - out: { arrayOf: TokenBalance } + out: { arrayOf: TokenBalance }, + description: + "Deprecated: returns all matching balances without pagination. " + + "Use FetchBalancesWithTokenMetadata, which supports pagination, instead.", + deprecated: true }) public async FetchBalances(ctx: GalaChainContext, dto: FetchBalancesDto): Promise { return fetchBalances(ctx, { ...dto, owner: await resolveUserAlias(ctx, dto.owner ?? ctx.callingUser) }); diff --git a/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap b/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap index 190df0bc68..062c05efa8 100644 --- a/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap +++ b/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap @@ -3736,7 +3736,8 @@ The key is generated by the caller and should be unique for each DTO. You can us }, }, { - "description": " Transaction is read only (evaluate). Allowed roles: EVALUATE.", + "deprecated": true, + "description": "Deprecated: returns all matching balances without pagination. Use FetchBalancesWithTokenMetadata, which supports pagination, instead. Transaction is read only (evaluate). Allowed roles: EVALUATE.", "dtoSchema": { "description": "Contains parameters for fetching balances. Each parameter is optional.", "properties": { @@ -4555,7 +4556,7 @@ The key is generated by the caller and should be unique for each DTO. You can us "type": "string", }, "instance": { - "description": "Token instance. Optional.", + "description": "Token instance. Optional, but required if created is provided.", "minLength": 1, "type": "string", }, diff --git a/chain-cli/src/commands/deploy/index.ts b/chain-cli/src/commands/deploy/index.ts index fb3878d7d7..42f1da085b 100644 --- a/chain-cli/src/commands/deploy/index.ts +++ b/chain-cli/src/commands/deploy/index.ts @@ -52,7 +52,7 @@ export default class Deploy extends BaseCommand { developerPrivateKey: Args.string({ char: "k", description: - "Developer's private key to sign the request. It could be a file or a string. " + + "Path to the developer's private key file used to sign the request. " + "If not provided as an argument, the command will try to read the private key " + "from the environment variable DEV_PRIVATE_KEY, or from the default location " + "(~/.gc-keys//gc-dev-key), or will ask for it in a prompt.", diff --git a/chain-cli/src/commands/dto-sign/index.spec.ts b/chain-cli/src/commands/dto-sign/index.spec.ts index 940a5d4406..0dca9a5c01 100644 --- a/chain-cli/src/commands/dto-sign/index.spec.ts +++ b/chain-cli/src/commands/dto-sign/index.spec.ts @@ -12,7 +12,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { mkdtempSync, writeFileSync } from "fs"; import { writeFile } from "fs/promises"; +import os from "os"; import path from "path"; import signatures from "../../signatures"; @@ -44,7 +46,13 @@ jest.mock("../../utils", () => ({ parseJsonFromStringOrFile: () => mockParseJsonFromStringOrFile() })); +const keyFile = path.join(mkdtempSync(path.join(os.tmpdir(), "dto-sign-")), "test-key"); + describe("DtoSign Command", () => { + beforeAll(() => { + writeFileSync(keyFile, fakePrivateKey); + }); + it("it should check signature field in the response", async () => { // Given const result: (string | Uint8Array)[] = []; @@ -53,7 +61,7 @@ describe("DtoSign Command", () => { return true; }); - const target = path.resolve(__dirname, "./test-key"); + const target = keyFile; // When await DtoSign.run([target, dataTestJson]); @@ -71,7 +79,7 @@ describe("DtoSign Command", () => { return true; }); - const target = path.resolve(__dirname, "./test-key"); + const target = keyFile; // When await DtoSign.run([target, dataTestJson, "-d"]); @@ -83,28 +91,72 @@ describe("DtoSign Command", () => { }); it("keeps an existing signer address on a DER signature", async () => { + // Given const result: (string | Uint8Array)[] = []; jest.spyOn(process.stdout, "write").mockImplementation((v) => { result.push(v); return true; }); + const signerAddress = `eth|${signatures.getEthAddress(signatures.getPublicKey(fakePrivateKey))}`; mockParseJsonFromStringOrFile.mockResolvedValueOnce({ tokenClass: { collection: "CLITest" }, - signerAddress: "eth|0xabc" + signerAddress }); - await DtoSign.run([path.resolve(__dirname, "./test-key"), dataTestJson, "-d"]); + // When + await DtoSign.run([keyFile, dataTestJson, "-d"]); + // Then const output = result.join(""); - expect(output).toContain("eth|0xabc"); + expect(output).toContain(signerAddress); expect(output).not.toContain("signerPublicKey"); }); + it("rejects a signer address that does not match the key on a DER signature", async () => { + // Given + const otherAddress = signatures.getEthAddress(signatures.getPublicKey("11".repeat(32))); + mockParseJsonFromStringOrFile.mockResolvedValueOnce({ + tokenClass: { collection: "CLITest" }, + signerAddress: `eth|${otherAddress}` + }); + + // When & Then + await expect(DtoSign.run([keyFile, dataTestJson, "-d"])).rejects.toThrow( + /signerAddress in the DTO .* does not match the signing key/ + ); + }); + + it("accepts a matching compressed signer public key on a DER signature", async () => { + // Given + jest.spyOn(process.stdout, "write").mockImplementation(() => true); + const compressed = signatures.getCompactBase64PublicKey(signatures.getPublicKey(fakePrivateKey)); + mockParseJsonFromStringOrFile.mockResolvedValueOnce({ + tokenClass: { collection: "CLITest" }, + signerPublicKey: compressed + }); + + // When & Then + await expect(DtoSign.run([keyFile, dataTestJson, "-d"])).resolves.toBeUndefined(); + }); + + it("rejects a signer public key that does not match the key on a DER signature", async () => { + // Given + mockParseJsonFromStringOrFile.mockResolvedValueOnce({ + tokenClass: { collection: "CLITest" }, + signerPublicKey: signatures.getPublicKey("11".repeat(32)) + }); + + // When & Then + await expect(DtoSign.run([keyFile, dataTestJson, "-d"])).rejects.toThrow( + /signerPublicKey in the DTO does not match the signing key/ + ); + }); + it("does not attach a signer identity when only the DER signature is requested", async () => { const der = jest.spyOn(signatures, "getDERSignature"); jest.spyOn(process.stdout, "write").mockImplementation(() => true); - await DtoSign.run([path.resolve(__dirname, "./test-key"), dataTestJson, "-d", "-s"]); + await DtoSign.run([keyFile, dataTestJson, "-d", "-s"]); const signed = der.mock.calls[0][0] as { signerPublicKey?: string }; expect(signed.signerPublicKey).toBeUndefined(); @@ -113,13 +165,19 @@ describe("DtoSign Command", () => { it("fails when the output file cannot be written", async () => { jest.mocked(writeFile).mockRejectedValueOnce(new Error("disk full")); - await expect( - DtoSign.run([path.resolve(__dirname, "./test-key"), dataTestJson, "-o", "signed.json"]) - ).rejects.toThrow(/Failed to writeFile to signed.json/); + await expect(DtoSign.run([keyFile, dataTestJson, "-o", "signed.json"])).rejects.toThrow( + /Failed to writeFile to signed.json/ + ); }); - it("rejects a raw private key", async () => { - await expect(DtoSign.run([fakePrivateKey, dataTestJson])).rejects.toThrow(/Pass a path to a key file/); + it.each([ + ["64 hex chars", fakePrivateKey], + ["0x-prefixed hex", `0x${fakePrivateKey}`], + ["62 hex chars", fakePrivateKey.slice(2)], + ["base64", Buffer.from(fakePrivateKey, "hex").toString("base64")] + ])("rejects a raw private key (%s)", async (_, key) => { + // When & Then + await expect(DtoSign.run([key, dataTestJson])).rejects.toThrow(/Pass a path to a private key file/); }); it("it should return only the signature", async () => { @@ -130,7 +188,7 @@ describe("DtoSign Command", () => { return true; }); - const target = path.resolve(__dirname, "./test-key"); + const target = keyFile; // When await DtoSign.run([target, dataTestJson, "-s"]); diff --git a/chain-cli/src/commands/dto-sign/index.ts b/chain-cli/src/commands/dto-sign/index.ts index 1a2efca26a..27e3953ecf 100644 --- a/chain-cli/src/commands/dto-sign/index.ts +++ b/chain-cli/src/commands/dto-sign/index.ts @@ -18,7 +18,7 @@ import { writeFile } from "fs/promises"; import BaseCommand from "../../base-command"; import signatures, { serialize } from "../../signatures"; -import { parseJsonFromStringOrFile, parseStringOrFileKey } from "../../utils"; +import { assertPrivateKeyFileArg, parseJsonFromStringOrFile, parseStringOrFileKey } from "../../utils"; export default class DtoSign extends BaseCommand { static override aliases = ["dto:sign"]; @@ -75,10 +75,10 @@ export default class DtoSign extends BaseCommand { async run(): Promise { const { flags, args } = await this.parse(DtoSign); - if (/^(0x)?[0-9a-fA-F]{64}$/.test(args.key.trim())) { - this.error("Pass a path to a key file. A raw private key is not accepted as the key argument.", { - exit: 1 - }); + try { + assertPrivateKeyFileArg(args.key); + } catch (e) { + this.error((e as Error).message, { exit: 1 }); } const privateKey = (await parseStringOrFileKey(args.key)) as string; @@ -90,6 +90,15 @@ export default class DtoSign extends BaseCommand { signerAddress?: string; }; + // A DER signature does not allow to recover the public key, so a mismatching + // signer identity would only be detected on chain. + if (flags.derSignature) { + const mismatch = this.getSignerIdentityMismatch(dto, privateKey); + if (mismatch) { + this.error(mismatch, { exit: 1 }); + } + } + if ( flags.derSignature && !flags.onlySignature && @@ -116,4 +125,37 @@ export default class DtoSign extends BaseCommand { this.log(output); } } + + private getSignerIdentityMismatch( + dto: { signerPublicKey?: string; signerAddress?: string }, + privateKey: string + ): string | undefined { + const publicKey = signatures.getPublicKey(privateKey); + + if (dto.signerPublicKey !== undefined) { + let provided: string; + try { + provided = signatures.getNonCompactHexPublicKey(dto.signerPublicKey); + } catch (e) { + return `Invalid signerPublicKey in the DTO: ${(e as Error).message}`; + } + if (provided !== publicKey) { + return "signerPublicKey in the DTO does not match the signing key."; + } + } + + if (dto.signerAddress !== undefined) { + // Only eth addresses can be checked locally. Other user refs (e.g. client|...) + // are resolved on chain. + const ethAddress = /^(eth\|)?(0x)?([0-9a-fA-F]{40})$/.exec(dto.signerAddress)?.[3]; + if ( + ethAddress !== undefined && + ethAddress.toLowerCase() !== signatures.getEthAddress(publicKey).toLowerCase() + ) { + return `signerAddress in the DTO (${dto.signerAddress}) does not match the signing key.`; + } + } + + return undefined; + } } diff --git a/chain-cli/src/commands/info/index.ts b/chain-cli/src/commands/info/index.ts index 588f2cb430..a9086fcde0 100644 --- a/chain-cli/src/commands/info/index.ts +++ b/chain-cli/src/commands/info/index.ts @@ -37,7 +37,7 @@ export default class Info extends BaseCommand { developerPrivateKey: Args.string({ char: "k", description: - "Developer's private key to sign the request. It could be a file or a string. " + + "Path to the developer's private key file used to sign the request. " + "If not provided as an argument, the command will try to read the private key " + "from the environment variable DEV_PRIVATE_KEY, or from the default location " + "(~/.gc-keys//gc-dev-key), or will ask for it in a prompt.", diff --git a/chain-cli/src/commands/init/index.ts b/chain-cli/src/commands/init/index.ts index f25b7dcbfa..17f59d28e7 100644 --- a/chain-cli/src/commands/init/index.ts +++ b/chain-cli/src/commands/init/index.ts @@ -38,7 +38,7 @@ export default class Init extends BaseCommand { const { args } = await this.parse(Init); try { - checkCliVersion(); + checkCliVersion(this.config.version); await this.confirmOverwrite(args.path); diff --git a/chain-cli/src/commands/logs/index.ts b/chain-cli/src/commands/logs/index.ts index c2b37ee7a7..56109a19b3 100644 --- a/chain-cli/src/commands/logs/index.ts +++ b/chain-cli/src/commands/logs/index.ts @@ -41,7 +41,7 @@ export default class Log extends BaseCommand { developerPrivateKey: Flags.string({ char: "k", description: - "Private key to sign the data. It can be a file path or a key string. " + + "Path to the private key file used to sign the data. " + "If not provided, the private key will be read from the environment variable DEV_PRIVATE_KEY.", required: false }), diff --git a/chain-cli/src/commands/network-up/index.spec.ts b/chain-cli/src/commands/network-up/index.spec.ts index f6b4edaffc..57ef10ec97 100644 --- a/chain-cli/src/commands/network-up/index.spec.ts +++ b/chain-cli/src/commands/network-up/index.spec.ts @@ -142,6 +142,14 @@ describe("createConfigtxProfiles", () => { ); }); + it.each(["curator-channel", "partner-channel", "CuratorChannel"])( + "rejects channel %s that would override a built-in configtx profile", + (channel) => { + // When & Then + expect(() => createConfigtxProfiles([arg(channel)])).toThrow(/reserved for a built-in profile/); + } + ); + it("emits a profile once when the same channel is listed more than once", () => { const yaml = createConfigtxProfiles([arg("product-channel"), arg("product-channel")]); diff --git a/chain-cli/src/commands/network-up/index.ts b/chain-cli/src/commands/network-up/index.ts index 019eb6fafb..f9a447d970 100644 --- a/chain-cli/src/commands/network-up/index.ts +++ b/chain-cli/src/commands/network-up/index.ts @@ -234,10 +234,19 @@ export function configtxProfileName(channel: string): string { return channel.replace(/(^\w|-\w)/g, (token) => token.replace(/-/, "").toUpperCase()); } +// Profiles defined in network/configtx-policies.yml. Channel profiles are appended to that file, +// so they must not reuse these names. +const builtInConfigtxProfileNames = ["CuratorChannel", "PartnerChannel"]; + export function assertUniqueConfigtxProfileNames(channels: string[]): void { const profileToChannel = new Map(); channels.forEach((channel) => { const profileName = configtxProfileName(channel); + if (builtInConfigtxProfileNames.includes(profileName)) { + throw new Error( + `Error: Channel name "${channel}" produces the configtx profile "${profileName}", which is reserved for a built-in profile. Use a different channel name.` + ); + } const existing = profileToChannel.get(profileName); if (existing !== undefined && existing !== channel) { throw new Error( diff --git a/chain-cli/src/commands/register/index.ts b/chain-cli/src/commands/register/index.ts index da11839c2e..5e91b27e3e 100644 --- a/chain-cli/src/commands/register/index.ts +++ b/chain-cli/src/commands/register/index.ts @@ -45,7 +45,7 @@ export default class Register extends BaseCommand { developerPrivateKey: Args.string({ char: "k", description: - "Developer's private key to sign the request. It could be a file or a string. " + + "Path to the developer's private key file used to sign the request. " + "If not provided as an argument, the command will try to read the private key " + "from the environment variable DEV_PRIVATE_KEY, or from the default location " + "(~/.gc-keys//gc-dev-key), or will ask for it in a prompt.", diff --git a/chain-cli/src/galachain-utils.keys.spec.ts b/chain-cli/src/galachain-utils.keys.spec.ts index 783b8d9ad4..a37daa3ae8 100644 --- a/chain-cli/src/galachain-utils.keys.spec.ts +++ b/chain-cli/src/galachain-utils.keys.spec.ts @@ -17,7 +17,7 @@ import fs from "fs"; import os from "os"; import path from "path"; -import { getDeveloperPublicKeys, saveApiConfig } from "./galachain-utils"; +import { getDeveloperPublicKeys, getPrivateKey, saveApiConfig } from "./galachain-utils"; describe("saveApiConfig contract names", () => { it("rejects a missing, empty, or duplicate contract name", async () => { @@ -54,6 +54,51 @@ describe("saveApiConfig contract names", () => { }; expect(stripped.channels[0].pathFragment).toEqual("product"); }); + + it("rejects channels with the same path fragment", async () => { + // Given + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "api-config-")); + const contracts = '[{"contractName":"Apple"}]'; + + // When & Then + await expect( + saveApiConfig(dir, [contracts, contracts], ["product-channel", "product"], ["basic", "basic"]) + ).rejects.toThrow(/Channels "product-channel" and "product" have the same API path fragment "product"/); + expect(fs.existsSync(path.join(dir, "api-config.json"))).toBe(false); + }); +}); + +describe("getPrivateKey", () => { + const privateKey = secp.utils.bytesToHex(secp.utils.randomPrivateKey()); + + it("accepts a path to a key file as an argument", async () => { + // Given + const keyFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dev-key-")), "gc-dev-key"); + fs.writeFileSync(keyFile, privateKey); + + // When & Then + await expect(getPrivateKey(keyFile, "gc-test")).resolves.toEqual(keyFile); + }); + + it("rejects a raw private key as an argument", async () => { + // When & Then + await expect(getPrivateKey(privateKey, "gc-test")).rejects.toThrow(/Pass a path to a private key file/); + await expect(getPrivateKey(`0x${privateKey}`, "gc-test")).rejects.toThrow( + /Pass a path to a private key file/ + ); + }); + + it("still reads a raw key from the environment variable", async () => { + // Given + process.env.DEV_PRIVATE_KEY = privateKey; + + // When & Then + try { + await expect(getPrivateKey(undefined, "gc-test")).resolves.toEqual(privateKey); + } finally { + delete process.env.DEV_PRIVATE_KEY; + } + }); }); describe("getDeveloperPublicKeys", () => { diff --git a/chain-cli/src/galachain-utils.ts b/chain-cli/src/galachain-utils.ts index b2a3d75a33..3796da8061 100644 --- a/chain-cli/src/galachain-utils.ts +++ b/chain-cli/src/galachain-utils.ts @@ -28,7 +28,7 @@ import { ChaincodeInfoDto, DeployChaincodeDto, GetChaincodeDeploymentDto, Regist import { BadRequestError, FetchLogsError, UnauthorizedError } from "./errors"; import { execFileSync } from "./exec-sync"; import signatures from "./signatures"; -import { parseStringOrFileKey } from "./utils"; +import { assertPrivateKeyFileArg, parseStringOrFileKey } from "./utils"; const ConfigFileName = ".galachainrc.json"; const PackageJsonFileName = "package.json"; @@ -349,43 +349,79 @@ export async function generateKeys(projectPath: string): Promise { console.log(`Private keys directory: ${privateKeysPath}`); } +// Compares versions by semver precedence: numeric release parts first, and a prerelease +// (e.g. 3.10.0-beta.1) is lower than the same release (3.10.0). Build metadata is ignored. export function isNewerVersion(latest: string, current: string): boolean { - const latestParts = versionParts(latest); - const currentParts = versionParts(current); - const length = Math.max(latestParts.length, currentParts.length); + return compareVersions(latest, current) > 0; +} + +function compareVersions(a: string, b: string): number { + const left = parseVersion(a); + const right = parseVersion(b); + const length = Math.max(left.release.length, right.release.length); for (let index = 0; index < length; index++) { - const latestPart = latestParts[index] ?? 0; - const currentPart = currentParts[index] ?? 0; - if (latestPart > currentPart) { - return true; + const diff = (left.release[index] ?? 0) - (right.release[index] ?? 0); + if (diff !== 0) { + return Math.sign(diff); } - if (latestPart < currentPart) { - return false; + } + + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + // a release is higher than any of its prereleases + return Math.sign(right.prerelease.length - left.prerelease.length); + } + + const prereleaseLength = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < prereleaseLength; index++) { + const l = left.prerelease[index]; + const r = right.prerelease[index]; + if (l === undefined || r === undefined) { + // a shorter set of identifiers is lower when all preceding ones are equal + return l === undefined ? -1 : 1; + } + const diff = comparePrereleaseIdentifiers(l, r); + if (diff !== 0) { + return diff; } } - return false; + return 0; } -function versionParts(version: string): number[] { - return version - .trim() - .replace(/^v/, "") - .split(".") - .map((part) => { - const numeric = parseInt(part, 10); - return Number.isNaN(numeric) ? 0 : numeric; - }); +function comparePrereleaseIdentifiers(a: string, b: string): number { + const aNumeric = /^\d+$/.test(a); + const bNumeric = /^\d+$/.test(b); + if (aNumeric && bNumeric) { + return Math.sign(parseInt(a, 10) - parseInt(b, 10)); + } + if (aNumeric !== bNumeric) { + // numeric identifiers are lower than alphanumeric ones + return aNumeric ? -1 : 1; + } + return a < b ? -1 : a > b ? 1 : 0; } -export function checkCliVersion() { +function parseVersion(version: string): { release: number[]; prerelease: string[] } { + const withoutBuild = version.trim().replace(/^v/, "").split("+")[0]; + const dashIndex = withoutBuild.indexOf("-"); + const releasePart = dashIndex === -1 ? withoutBuild : withoutBuild.slice(0, dashIndex); + const prereleasePart = dashIndex === -1 ? "" : withoutBuild.slice(dashIndex + 1); + + const release = releasePart.split(".").map((part) => { + const numeric = parseInt(part, 10); + return Number.isNaN(numeric) ? 0 : numeric; + }); + const prerelease = prereleasePart === "" ? [] : prereleasePart.split("."); + + return { release, prerelease }; +} + +export function checkCliVersion(cliCurrentVersion: string) { let cliLatestVersion: string; - let cliCurrentVersion: string; try { - cliLatestVersion = execFileSync("npm", ["show", "@gala-chain/cli", "version"]); - cliCurrentVersion = execFileSync("galachain", ["--version"]).split(" ")[0].split("/")[2]; + cliLatestVersion = execFileSync("npm", ["show", "@gala-chain/cli", "version"]).trim(); } catch (error) { console.warn(`Unable to compare Chain CLI versions: ${error}`); return; @@ -455,6 +491,9 @@ export async function getDeveloperPublicKeys(): Promise { } export async function getPrivateKey(keysFromArg: string | undefined, chaincodeName: string) { + if (keysFromArg !== undefined) { + assertPrivateKeyFileArg(keysFromArg); + } return ( keysFromArg ?? process.env.DEV_PRIVATE_KEY ?? @@ -479,6 +518,18 @@ export async function saveApiConfig( ) }; + const channelsByPathFragment = new Map(); + for (const { pathFragment, channelName } of apiConfig.channels) { + const existing = channelsByPathFragment.get(pathFragment); + if (existing !== undefined) { + throw new Error( + `Channels "${existing}" and "${channelName}" have the same API path fragment "${pathFragment}". ` + + "Use channel names that differ after removing the -channel suffix." + ); + } + channelsByPathFragment.set(pathFragment, channelName); + } + // Ensure directory exists if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); diff --git a/chain-cli/src/galachain-utils.version.spec.ts b/chain-cli/src/galachain-utils.version.spec.ts index 432c57ac7f..4e71f06ec6 100644 --- a/chain-cli/src/galachain-utils.version.spec.ts +++ b/chain-cli/src/galachain-utils.version.spec.ts @@ -22,23 +22,64 @@ describe("isNewerVersion", () => { expect(isNewerVersion("3.9.0", "3.9.0")).toBe(false); expect(isNewerVersion("3.9.1", "3.9.0")).toBe(true); }); + + it("treats a prerelease as lower than the same release", () => { + // When & Then + expect(isNewerVersion("3.10.0", "3.10.0-beta.1")).toBe(true); + expect(isNewerVersion("3.10.0-beta.1", "3.10.0")).toBe(false); + expect(isNewerVersion("3.10.0-beta.1", "3.9.0")).toBe(true); + expect(isNewerVersion("3.10.0-beta.1", "3.10.0-beta.1")).toBe(false); + }); + + it("orders prereleases by their identifiers", () => { + // When & Then + expect(isNewerVersion("3.10.0-beta.2", "3.10.0-beta.1")).toBe(true); + expect(isNewerVersion("3.10.0-beta.10", "3.10.0-beta.2")).toBe(true); + expect(isNewerVersion("3.10.0-beta", "3.10.0-alpha.5")).toBe(true); + expect(isNewerVersion("3.10.0-beta.1", "3.10.0-beta")).toBe(true); + expect(isNewerVersion("3.10.0-alpha", "3.10.0-1")).toBe(true); + }); }); describe("checkCliVersion", () => { afterEach(() => jest.restoreAllMocks()); it("warns when the published version is numerically newer", () => { - jest.spyOn(exec, "execFileSync").mockImplementation((command) => { - if (command === "npm") { - return "3.10.0"; - } - return "@gala-chain/cli/3.9.0 darwin-arm64 node-v24.0.0"; - }); + // Given + const execSpy = jest.spyOn(exec, "execFileSync").mockReturnValue("3.10.0"); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + // When + checkCliVersion("3.9.0"); + + // Then + expect(warn).toHaveBeenCalledWith(expect.stringContaining("current version is 3.9.0")); + expect(execSpy).toHaveBeenCalledTimes(1); + expect(execSpy).toHaveBeenCalledWith("npm", ["show", "@gala-chain/cli", "version"]); + }); + + it("warns when the running version is a prerelease of the published version", () => { + // Given + jest.spyOn(exec, "execFileSync").mockReturnValue("3.10.0"); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + // When + checkCliVersion("3.10.0-beta.1"); + + // Then + expect(warn).toHaveBeenCalledWith(expect.stringContaining("latest version is 3.10.0")); + }); + + it("does not warn when the running version is up to date", () => { + // Given + jest.spyOn(exec, "execFileSync").mockReturnValue("3.10.0"); const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); - checkCliVersion(); + // When + checkCliVersion("3.10.0"); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("3.10.0")); + // Then + expect(warn).not.toHaveBeenCalled(); }); it("does not abort when the version lookup fails", () => { @@ -47,7 +88,7 @@ describe("checkCliVersion", () => { }); const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); - expect(() => checkCliVersion()).not.toThrow(); + expect(() => checkCliVersion("3.9.0")).not.toThrow(); expect(warn).toHaveBeenCalledWith(expect.stringContaining("registry unavailable")); }); }); diff --git a/chain-cli/src/signatures/eth.ts b/chain-cli/src/signatures/eth.ts index c110e8d244..67797278a4 100644 --- a/chain-cli/src/signatures/eth.ts +++ b/chain-cli/src/signatures/eth.ts @@ -137,8 +137,7 @@ function getNonCompactHexPublicKey(publicKey: string): string { } function getPublicKey(privateKey: string): string { - const privateKeyHex = privateKey.replace(/^0x/, ""); - const privateKeyBuffer = Buffer.from(privateKeyHex, "hex"); + const privateKeyBuffer = normalizePrivateKey(privateKey); const publicKey = secp256k1.publicKeyCreate(new Uint8Array(privateKeyBuffer), false); return Buffer.from(publicKey).toString("hex"); } diff --git a/chain-cli/src/utils.spec.ts b/chain-cli/src/utils.spec.ts index c4d8f80215..d51c839cb1 100644 --- a/chain-cli/src/utils.spec.ts +++ b/chain-cli/src/utils.spec.ts @@ -14,7 +14,15 @@ */ import fs from "fs/promises"; -import { getPathFileName, parseJsonFromStringOrFile, readPublicKeyFromFile } from "./utils"; +import { + assertPrivateKeyFileArg, + getPathFileName, + parseJsonFromStringOrFile, + parseStringOrFileKey, + readPublicKeyFromFile +} from "./utils"; + +const privateKeyHex = "45f2db331d77c0154c70be06d7d9fe00fa2b5471872f134d73a6e43c6b7e3d29"; describe("parseJsonFromStringOrFile", () => { it("should parse JSON string", async () => { @@ -118,3 +126,75 @@ describe("getPathFileName", () => { expect(getPathFileName(".\\my-project\\")).toEqual("my-project"); }); }); + +describe("assertPrivateKeyFileArg", () => { + const keyFile = "./private-key-arg.key"; + + beforeAll(() => fs.writeFile(keyFile, privateKeyHex)); + afterAll(() => fs.unlink(keyFile)); + + it("accepts a path to an existing file", () => { + // When & Then + expect(() => assertPrivateKeyFileArg(keyFile)).not.toThrow(); + }); + + it.each([ + ["64 hex chars", privateKeyHex], + ["0x-prefixed hex", `0x${privateKeyHex}`], + ["61 hex chars", privateKeyHex.slice(3)], + ["66 hex chars", `00${privateKeyHex}`], + ["0x-prefixed 66 hex chars", `0x00${privateKeyHex}`], + ["base64", Buffer.from(privateKeyHex, "hex").toString("base64")], + ["unpadded base64", Buffer.from(privateKeyHex, "hex").toString("base64").replace(/=$/, "")] + ])("rejects a raw private key (%s) without printing it", (_, key) => { + // When & Then + expect(() => assertPrivateKeyFileArg(key)).toThrow(/Pass a path to a private key file/); + expect(() => assertPrivateKeyFileArg(key)).not.toThrow(key); + }); + + it("rejects a path to a missing file", () => { + // When & Then + expect(() => assertPrivateKeyFileArg("./missing-key-file")).toThrow( + "Private key file not found: ./missing-key-file" + ); + }); +}); + +describe("parseStringOrFileKey", () => { + it("reads the key from a file", async () => { + // Given + const keyFile = "./parse-key-valid.key"; + await fs.writeFile(keyFile, `${privateKeyHex}\n`); + + // When + const key = await parseStringOrFileKey(keyFile); + + // Then + expect(key).toEqual(privateKeyHex); + await fs.unlink(keyFile); + }); + + it("does not print an invalid key", async () => { + // Given + const invalid = "!!" + privateKeyHex.slice(2); + + // When + const error = await parseStringOrFileKey(invalid).catch((e) => e as Error); + + // Then + expect(error).toEqual(new Error("Failed to parse private key.")); + }); + + it("includes the file path when the file does not contain a valid key", async () => { + // Given + const keyFile = "./parse-key-invalid.key"; + await fs.writeFile(keyFile, "not a key"); + + // When + const error = await parseStringOrFileKey(keyFile).catch((e) => e as Error); + + // Then + expect(error).toEqual(new Error(`Failed to parse private key from file: ${keyFile}.`)); + await fs.unlink(keyFile); + }); +}); diff --git a/chain-cli/src/utils.ts b/chain-cli/src/utils.ts index c66b88fe3a..7d57d1cebf 100644 --- a/chain-cli/src/utils.ts +++ b/chain-cli/src/utils.ts @@ -12,6 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { statSync } from "fs"; import { readFile } from "fs/promises"; import signatures from "./signatures"; @@ -23,26 +24,57 @@ export async function readPublicKeyFromFile(path: string): Promise { return contents.trim(); } +function isExistingFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function looksLikeRawPrivateKey(value: string): boolean { + const trimmed = value.trim(); + if (/^(0x)?[0-9a-fA-F]{60,66}$/.test(trimmed)) { + return true; + } + return /^[A-Za-z0-9+/]{43}=?$/.test(trimmed) && Buffer.from(trimmed, "base64").length === 32; +} + +/** + * Private keys passed as command arguments end up in shell history and process lists, + * so a key argument must be a path to a key file. + */ +export function assertPrivateKeyFileArg(keyArg: string): void { + if (isExistingFile(keyArg)) { + return; + } + if (looksLikeRawPrivateKey(keyArg)) { + throw new Error( + "A raw private key is not accepted as an argument. Pass a path to a private key file instead." + ); + } + throw new Error(`Private key file not found: ${keyArg}. Pass a path to a private key file.`); +} + +// Accepts a path to a key file, or a raw key from sources that are not command arguments +// (environment variable, prompt, default key file contents). export async function parseStringOrFileKey(stringOrPath: string): Promise { - const errorMessages: string[] = []; + if (isExistingFile(stringOrPath)) { + try { + const keyFromFile = (await readFile(stringOrPath, { encoding: "utf-8" })).trim(); + signatures.normalizePrivateKey(keyFromFile); + return keyFromFile; + } catch (e) { + throw new Error(`Failed to parse private key from file: ${stringOrPath}.`); + } + } try { signatures.normalizePrivateKey(stringOrPath); return stringOrPath; } catch (e) { - errorMessages.push(`Fail to parse ${stringOrPath} as a valid private key.`); + throw new Error("Failed to parse private key."); } - - try { - const keyFromFile = (await readFile(stringOrPath, { encoding: "utf-8" })).trim(); - signatures.normalizePrivateKey(keyFromFile); - return keyFromFile; - } catch (e) { - errorMessages.push(`No valid private key found at ${stringOrPath}.`); - } - - errorMessages.push("Provide either a valid private key or file path to a valid private key."); - throw new Error(errorMessages.join("\n")); } export async function parseJsonFromStringOrFile(jsonOrPath: string): Promise { From 16d520bc589d7307ef715a11c60413d619d41453 Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 29 Oct 2026 18:59:54 +0100 Subject: [PATCH 6/7] Fix: Page a balance query and keep $ at the start of a key part FetchBalances accepts a page size and a bookmark. A string-key part may use $ only as its first character, so aaa and $bb stay distinct from a part that contains $ later. Co-authored-by: Cursor --- chain-api/src/types/ChainObject.spec.ts | 8 +- chain-api/src/types/ChainObject.ts | 23 +- chain-api/src/types/RangedChainObject.spec.ts | 3 +- chain-api/src/types/TokenClass.spec.ts | 19 +- chain-api/src/types/TokenInstance.spec.ts | 9 +- chain-api/src/types/TokenInstance.ts | 2 +- chain-api/src/types/token.ts | 16 + .../e2e/__snapshots__/api.spec.ts.snap | 449 ++++++++++-------- .../chaincode-template/e2e/burnNFT.spec.ts | 10 +- .../e2e/setupTransferFees.ts | 3 +- .../chaincode-template/e2e/simpleNFT.spec.ts | 6 +- chain-cli/chaincode-template/e2e/tokenOps.ts | 18 +- .../src/token/GalaChainTokenContract.ts | 22 +- .../src/token/__snapshots__/api.spec.ts.snap | 449 ++++++++++-------- chain-connect/src/chainApis/TokenApi.ts | 5 +- .../src/__test__/GalaChainTokenContract.ts | 17 +- chaincode/src/balances/fetchBalances.spec.ts | 46 +- 17 files changed, 684 insertions(+), 421 deletions(-) diff --git a/chain-api/src/types/ChainObject.spec.ts b/chain-api/src/types/ChainObject.spec.ts index 306f3c7e90..1d8106260c 100644 --- a/chain-api/src/types/ChainObject.spec.ts +++ b/chain-api/src/types/ChainObject.spec.ts @@ -126,9 +126,13 @@ it("accepts a well-formed nested composite key as a part", () => { expect(ChainObject.joinKeyParts([nested, "x"])).toEqual(`${nested}\u0000x`); }); -it("allows $ but rejects NUL inside a string key part", () => { +it("allows $ only as the first character of a string key part", () => { + expect(ChainObject.getStringKeyFromParts(["aaa", "$bb"])).toEqual("aaa$$bb"); + expect(ChainObject.getPartsFromStringKey("aaa$$bb")).toEqual(["aaa", "$bb"]); expect(ChainObject.getStringKeyFromParts(["a", "b_c"])).toEqual("a$b_c"); - expect(ChainObject.getStringKeyFromParts(["a$b", "c"])).toEqual("a$b$c"); + expect(ChainObject.getPartsFromStringKey("a$b_c")).toEqual(["a", "b_c"]); + expect(() => ChainObject.getStringKeyFromParts(["aaa$", "bb"])).toThrow(/first character/); + expect(() => ChainObject.getStringKeyFromParts(["aa$a", "bb"])).toThrow(/first character/); expect(() => ChainObject.getStringKeyFromParts(["a\u0000b"])).toThrow(/NUL/); }); diff --git a/chain-api/src/types/ChainObject.ts b/chain-api/src/types/ChainObject.ts index bf2052fd8e..bb50f92dc5 100644 --- a/chain-api/src/types/ChainObject.ts +++ b/chain-api/src/types/ChainObject.ts @@ -168,18 +168,37 @@ export abstract class ChainObject { } public static getStringKeyFromParts(parts: string[]): string { - // The "$" separator is not rejected here: existing token classes already use it in key fields, - // and their string keys must stay readable in the existing format. + // "$" may be the first character of a part. "aaa" + "$bb" is "aaa$$bb". + // A "$" later in the part is rejected, so "aaa$" + "bb" cannot produce the same string. const texts = parts.map((part, index) => { const text = part === null || part === undefined ? "" : String(part); if (text.includes(ChainObject.MIN_UNICODE_RUNE_VALUE)) { throw new InvalidCompositeKeyError(`string key part at index ${index} contains a NUL character`); } + if (text.indexOf(ChainObject.ID_SPLIT_CHAR) > 0) { + throw new InvalidCompositeKeyError( + `string key part at index ${index} contains $ after its first character` + ); + } return text; }); return texts.join(ChainObject.ID_SPLIT_CHAR); } + public static getPartsFromStringKey(stringKey: string): string[] { + const raw = stringKey.split(ChainObject.ID_SPLIT_CHAR); + const parts: string[] = []; + for (let i = 0; i < raw.length; i++) { + if (raw[i] === "" && i + 1 < raw.length) { + parts.push(`${ChainObject.ID_SPLIT_CHAR}${raw[i + 1]}`); + i += 1; + } else { + parts.push(raw[i]); + } + } + return parts; + } + public static getEncodableStringKeyFromParts(parts: string[]): string { parts.forEach((part, index) => { if (part.includes(ChainObject.MIN_UNICODE_RUNE_VALUE)) { diff --git a/chain-api/src/types/RangedChainObject.spec.ts b/chain-api/src/types/RangedChainObject.spec.ts index f4a2a2c50e..9b5efc443c 100644 --- a/chain-api/src/types/RangedChainObject.spec.ts +++ b/chain-api/src/types/RangedChainObject.spec.ts @@ -55,7 +55,8 @@ it("rejects a NUL character inside a ranged key part", () => { expect(() => RangedChainObject.getRangedKeyFromParts("idx", ["a\u0000b", "c"])).toThrow(/NUL/); expect(() => RangedChainObject.getRangedKeyFromParts("id\u0000x", ["a"])).toThrow(/NUL/); expect(RangedChainObject.getStringKeyFromParts(["a", "b_c"])).toEqual("a$b_c"); - expect(RangedChainObject.getStringKeyFromParts(["a$b", "c"])).toEqual("a$b$c"); + expect(RangedChainObject.getStringKeyFromParts(["aaa", "$bb"])).toEqual("aaa$$bb"); + expect(() => RangedChainObject.getStringKeyFromParts(["a$b", "c"])).toThrow(/first character/); expect(() => RangedChainObject.getRangedKeyFromParts("idx", ["\u0000a\u0000\u0000\u0000b\u0000"])).toThrow( /NUL/ ); diff --git a/chain-api/src/types/TokenClass.spec.ts b/chain-api/src/types/TokenClass.spec.ts index 8f3b5eb6d8..43fa674b79 100644 --- a/chain-api/src/types/TokenClass.spec.ts +++ b/chain-api/src/types/TokenClass.spec.ts @@ -177,11 +177,11 @@ it("should encode and decode successfully with utf 0 in keys", async () => { expect(decode).toThrow("Expected 4 parts, got 5 parts"); }); -it("should build a string key when key fields contain $", () => { +it("should build a string key when a key field starts with $", () => { // Given const classKey = new TokenClassKey(); - classKey.collection = "$Test"; - classKey.category = "Cl$ass"; + classKey.collection = "aaa"; + classKey.category = "$bb"; classKey.type = "Key"; classKey.additionalKey = "none"; @@ -189,5 +189,16 @@ it("should build a string key when key fields contain $", () => { const stringKey = classKey.toStringKey(); // Then - expect(stringKey).toEqual("$Test$Cl$ass$Key$none"); + expect(stringKey).toEqual("aaa$$bb$Key$none"); + expect(ChainObject.getPartsFromStringKey(stringKey)).toEqual(["aaa", "$bb", "Key", "none"]); +}); + +it("rejects $ after the first character of a token class key field", () => { + const classKey = new TokenClassKey(); + classKey.collection = "aaa"; + classKey.category = "Cl$ass"; + classKey.type = "Key"; + classKey.additionalKey = "none"; + + expect(() => classKey.toStringKey()).toThrow(/first character/); }); diff --git a/chain-api/src/types/TokenInstance.spec.ts b/chain-api/src/types/TokenInstance.spec.ts index 02e83e29e2..ba3affc4a0 100644 --- a/chain-api/src/types/TokenInstance.spec.ts +++ b/chain-api/src/types/TokenInstance.spec.ts @@ -96,11 +96,11 @@ it("builds an instance key from the whole integer", () => { ); }); -it("should build a string key when key fields contain $", () => { +it("should build a string key when a key field starts with $", () => { // Given const instance = new TokenInstanceKey(); - instance.collection = "$Test"; - instance.category = "Ve$ry"; + instance.collection = "aaa"; + instance.category = "$bb"; instance.type = "Large"; instance.additionalKey = "none"; instance.instance = new BigNumber(0); @@ -109,5 +109,6 @@ it("should build a string key when key fields contain $", () => { const stringKey = instance.toStringKey(); // Then - expect(stringKey).toEqual("$Test$Ve$ry$Large$none$0"); + expect(stringKey).toEqual("aaa$$bb$Large$none$0"); + expect(ChainObject.getPartsFromStringKey(stringKey)).toEqual(["aaa", "$bb", "Large", "none", "0"]); }); diff --git a/chain-api/src/types/TokenInstance.ts b/chain-api/src/types/TokenInstance.ts index 8fe38d04b5..a4469fa836 100644 --- a/chain-api/src/types/TokenInstance.ts +++ b/chain-api/src/types/TokenInstance.ts @@ -356,7 +356,7 @@ export class TokenInstance extends ChainObject { // This parses a string into the key that can be used to query HLF // This looks to be built to only handle the key to be composed of two parts and seperated by a | public static GetCompositeKeyFromString(tokenCid: string): string { - const idParts = tokenCid.split(ChainObject.ID_SPLIT_CHAR); + const idParts = ChainObject.getPartsFromStringKey(tokenCid); const instanceText = idParts[1]; const canonicalInstance = /^(0|[1-9]\d*)$/.test(instanceText ?? ""); diff --git a/chain-api/src/types/token.ts b/chain-api/src/types/token.ts index fe734e38f9..7fcea896e9 100644 --- a/chain-api/src/types/token.ts +++ b/chain-api/src/types/token.ts @@ -411,6 +411,22 @@ export class FetchBalancesDto extends ChainCallDTO { }) @IsOptional() additionalKey?: string; + + @JSONSchema({ + description: "Page bookmark. If it is undefined, then the first page is returned." + }) + @IsOptional() + @IsNotEmpty() + bookmark?: string; + + @JSONSchema({ + description: "Page size. When omitted, the query uses the total results limit. Maximum 10000." + }) + @IsOptional() + @Max(10 * 1000) + @Min(1) + @IsInt() + limit?: number; } @JSONSchema({ diff --git a/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap b/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap index 5c75f23811..258c6ee213 100644 --- a/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap +++ b/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap @@ -5224,14 +5224,18 @@ The key is generated by the caller and should be unique for each DTO. You can us }, }, { - "deprecated": true, - "description": "Deprecated: returns all matching balances without pagination. Use FetchBalancesWithTokenMetadata, which supports pagination, instead. Transaction is read only (evaluate). Allowed roles: EVALUATE.", + "description": "Returns a page of balances. Provide limit and bookmark to page. When limit is omitted, the query uses the total results limit. Transaction is read only (evaluate). Allowed roles: EVALUATE.", "dtoSchema": { "description": "Contains parameters for fetching balances. Each parameter is optional.", "properties": { "additionalKey": { "description": "Token additionalKey.", }, + "bookmark": { + "description": "Page bookmark. If it is undefined, then the first page is returned.", + "minLength": 1, + "type": "string", + }, "category": { "description": "Token category. Optional, but required if type is provided.", "minLength": 1, @@ -5255,6 +5259,12 @@ The key is generated by the caller and should be unique for each DTO. You can us "minLength": 1, "type": "string", }, + "limit": { + "description": "Page size. When omitted, the query uses the total results limit. Maximum 10000.", + "maximum": 10000, + "minimum": 1, + "type": "number", + }, "multisig": { "description": "List of signatures for this DTO if there are multiple signers. If there are multiple signatures, 'signerAddress' is required, and it is not allowed to provide 'signature' or 'signerPublicKey' fields, and the signing scheme must be ETH.", "items": {}, @@ -5313,214 +5323,281 @@ The key is generated by the caller and should be unique for each DTO. You can us "responseSchema": { "properties": { "Data": { - "items": { - "properties": { - "additionalKey": { - "not": { - "type": "null", - }, - }, - "category": { - "minLength": 1, - "type": "string", - }, - "collection": { - "minLength": 1, - "type": "string", - }, - "inUseHolds": { - "description": "Deprecated. Legacy holds from the removed use/release feature; retained for old state.", - "items": { - "properties": { - "created": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", - "exclusiveMinimum": 0, - "type": "number", - }, - "createdBy": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", - }, - "expires": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", - "minimum": 0, - "type": "number", - }, - "instanceId": { - "description": "Number provided as a string.", - "minLength": 1, - "type": "string", - }, - "lockAuthority": { - "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", - "type": "string", - }, - "name": { - "minLength": 1, - "type": "string", - }, - "quantity": { - "description": "Number provided as a string.", - "type": "string", - }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", - "minimum": 0, - "type": "number", - }, - }, - "required": [ - "createdBy", - "instanceId", - "quantity", - "created", - "expires", - ], - "type": "object", - }, - "type": "array", - }, - "instanceIds": {}, - "limit": { - "description": "Optional owner quantity limit and hourly spend buckets (current hour plus the preceding 23). When quantity is set, it takes precedence over TokenClass.quantityLimit.", + "properties": { + "domain": { + "description": "EIP-712 signing domain. Present when the DTO is signed as typed data. The name is GalaChain on mainnet, GalaChainStage on stage, and GalaChainDev otherwise. Not a message field; ignored by personal_sign payloads.", + "type": "object", + }, + "dtoExpiresAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when the DTO expires. If this time is before the current time, the DTO is not valid.", + "type": "number", + }, + "dtoOperation": { + "description": "Full operation identifier that is called on chain with this DTO. The format is \`channelId_chaincodeId_methodName\`. It is required for multisig DTOs and for any DTO that carries a signature. ", + "minLength": 1, + "type": "string", + }, + "multisig": { + "description": "List of signatures for this DTO if there are multiple signers. If there are multiple signatures, 'signerAddress' is required, and it is not allowed to provide 'signature' or 'signerPublicKey' fields, and the signing scheme must be ETH.", + "items": {}, + "minItems": 2, + "type": "array", + }, + "nextPageBookmark": { + "description": "Next page bookmark.", + "type": "string", + }, + "prefix": { + "description": "Prefix for Metamask transaction signatures. Necessary to format payloads correctly to recover publicKey from web3 signatures.", + "minLength": 1, + "type": "string", + }, + "results": { + "description": "List of balances with token metadata.", + "items": { "properties": { - "hours": { - "description": "Spend per hourly bucket. Index is unixHour mod 24. Entering hour H zeros hours[H] (yesterday's same hour) and any skipped hours since lastHour, then new spend is added to hours[H].", - }, - "lastHour": { - "description": "Unix hour (floor(ms / 3600000)) of the last recorded spend.", - "minimum": 0, - "type": "number", - }, - "pendingAppliesAt": { - "description": "Unix epoch timestamp in milliseconds (ms) when pendingQuantity becomes effective.", - "minimum": 0, - "type": "number", + "additionalKey": { + "not": { + "type": "null", + }, }, - "pendingQuantity": { - "description": "Owner-requested quantity that is not yet effective. Applied at pendingAppliesAt when the requested limit is an increase versus the current effective limit. Number provided as a string.", + "category": { + "minLength": 1, "type": "string", }, - "quantity": { - "description": "Owner override for TokenClass.quantityLimit. When set, this value is the maximum that may be subtracted from this balance across the current hour and the preceding 23 hourly buckets. Number provided as a string.", + "collection": { + "minLength": 1, "type": "string", }, - }, - "type": "object", - }, - "lockedHolds": { - "items": { - "properties": { - "created": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", - "exclusiveMinimum": 0, - "type": "number", - }, - "createdBy": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", - }, - "expires": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", - "minimum": 0, - "type": "number", - }, - "instanceId": { - "description": "Number provided as a string.", - "minLength": 1, - "type": "string", - }, - "lockAuthority": { - "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", - "type": "string", - }, - "name": { - "minLength": 1, - "type": "string", - }, - "quantity": { - "description": "Number provided as a string.", - "type": "string", + "inUseHolds": { + "description": "Deprecated. Legacy holds from the removed use/release feature; retained for old state.", + "items": { + "properties": { + "created": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", + "exclusiveMinimum": 0, + "type": "number", + }, + "createdBy": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "expires": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", + "minimum": 0, + "type": "number", + }, + "instanceId": { + "description": "Number provided as a string.", + "minLength": 1, + "type": "string", + }, + "lockAuthority": { + "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "quantity": { + "description": "Number provided as a string.", + "type": "string", + }, + "vestingPeriodStart": { + "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", + "minimum": 0, + "type": "number", + }, + }, + "required": [ + "createdBy", + "instanceId", + "quantity", + "created", + "expires", + ], + "type": "object", }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", - "minimum": 0, - "type": "number", + "type": "array", + }, + "instanceIds": {}, + "limit": { + "description": "Optional owner quantity limit and hourly spend buckets (current hour plus the preceding 23). When quantity is set, it takes precedence over TokenClass.quantityLimit.", + "properties": { + "hours": { + "description": "Spend per hourly bucket. Index is unixHour mod 24. Entering hour H zeros hours[H] (yesterday's same hour) and any skipped hours since lastHour, then new spend is added to hours[H].", + }, + "lastHour": { + "description": "Unix hour (floor(ms / 3600000)) of the last recorded spend.", + "minimum": 0, + "type": "number", + }, + "pendingAppliesAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when pendingQuantity becomes effective.", + "minimum": 0, + "type": "number", + }, + "pendingQuantity": { + "description": "Owner-requested quantity that is not yet effective. Applied at pendingAppliesAt when the requested limit is an increase versus the current effective limit. Number provided as a string.", + "type": "string", + }, + "quantity": { + "description": "Owner override for TokenClass.quantityLimit. When set, this value is the maximum that may be subtracted from this balance across the current hour and the preceding 23 hourly buckets. Number provided as a string.", + "type": "string", + }, }, + "type": "object", }, - "required": [ - "createdBy", - "instanceId", - "quantity", - "created", - "expires", - ], - "type": "object", - }, - "type": "array", - }, - "owner": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", - }, - "quantity": { - "description": "Number provided as a string.", - "type": "string", - }, - "targets": { - "description": "Optional destination restrictions for fungible subtract.", - "properties": { - "allowed": { - "description": "Allowed transfer destinations. undefined = no restriction. Empty array = frozen (no destinations). Non-empty = only those user aliases, stored sorted and unique. Burn is not a destination; empty list allows it as an edge case.", + "lockedHolds": { "items": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", + "properties": { + "created": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", + "exclusiveMinimum": 0, + "type": "number", + }, + "createdBy": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "expires": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", + "minimum": 0, + "type": "number", + }, + "instanceId": { + "description": "Number provided as a string.", + "minLength": 1, + "type": "string", + }, + "lockAuthority": { + "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "quantity": { + "description": "Number provided as a string.", + "type": "string", + }, + "vestingPeriodStart": { + "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", + "minimum": 0, + "type": "number", + }, + }, + "required": [ + "createdBy", + "instanceId", + "quantity", + "created", + "expires", + ], + "type": "object", }, - "maxItems": 32, "type": "array", }, - "pendingAllowAll": { - "description": "When true, pending change clears allowed (no restriction) at pendingAppliesAt.", - "type": "boolean", + "owner": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", }, - "pendingAllowed": { - "description": "Pending allowed list. Applied at pendingAppliesAt.", - "items": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", + "quantity": { + "description": "Number provided as a string.", + "type": "string", + }, + "targets": { + "description": "Optional destination restrictions for fungible subtract.", + "properties": { + "allowed": { + "description": "Allowed transfer destinations. undefined = no restriction. Empty array = frozen (no destinations). Non-empty = only those user aliases, stored sorted and unique. Burn is not a destination; empty list allows it as an edge case.", + "items": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "maxItems": 32, + "type": "array", + }, + "pendingAllowAll": { + "description": "When true, pending change clears allowed (no restriction) at pendingAppliesAt.", + "type": "boolean", + }, + "pendingAllowed": { + "description": "Pending allowed list. Applied at pendingAppliesAt.", + "items": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "maxItems": 32, + "type": "array", + }, + "pendingAppliesAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when the pending target change becomes effective.", + "minimum": 0, + "type": "number", + }, }, - "maxItems": 32, - "type": "array", + "type": "object", }, - "pendingAppliesAt": { - "description": "Unix epoch timestamp in milliseconds (ms) when the pending target change becomes effective.", + "type": { + "minLength": 1, + "type": "string", + }, + "vestingPeriodStart": { + "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart. For vesting locks, this is the beginning of the vesting period.", "minimum": 0, "type": "number", }, }, + "required": [ + "owner", + "collection", + "category", + "type", + "additionalKey", + "quantity", + ], "type": "object", }, - "type": { - "minLength": 1, - "type": "string", - }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart. For vesting locks, this is the beginning of the vesting period.", - "minimum": 0, - "type": "number", - }, + "type": "array", + }, + "signature": { + "description": "Signature of the DTO signed with caller's private key to be verified with user's public key saved on chain. The 'signature' field is optional for DTO, but is required for a transaction to be executed on chain. +Please consult [GalaChain SDK documentation](https://github.com/GalaChain/sdk/blob/main/docs/authorization.md#signature-based-authorization) on how to create signatures.", + "minLength": 1, + "type": "string", + }, + "signerAddress": { + "description": "Address of the user who signed the DTO. Typically Ethereum address, or user alias.", + "minLength": 1, + "type": "string", + }, + "signerPublicKey": { + "description": "Public key of the user who signed the DTO.", + "minLength": 1, + "type": "string", + }, + "trace": { + "description": "OpenTelemetry trace context propagated from the caller for chaincode log correlation. Not part of the signed payload.", + "type": "object", + }, + "types": { + "description": "EIP-712 type definitions for the signed message. Present when the DTO is signed as typed data. Not a message field; ignored by personal_sign payloads.", + "type": "object", + }, + "uniqueKey": { + "description": "Unique key of the DTO. It is used to prevent double execution of the same transaction on chain. The key is saved on chain and checked before execution. If a DTO with already saved key is used in transaction, the transaction will fail with UniqueTransactionConflict error, which is mapped to HTTP 409 Conflict error. In case of the error, no changes are saved to chain state. +The key is generated by the caller and should be unique for each DTO. You can use \`nanoid\` library, UUID scheme, or any tool to generate unique string keys.", + "minLength": 1, + "type": "string", }, - "required": [ - "owner", - "collection", - "category", - "type", - "additionalKey", - "quantity", - ], - "type": "object", }, - "type": "array", + "required": [ + "results", + ], + "type": "object", }, "Message": { "type": "string", diff --git a/chain-cli/chaincode-template/e2e/burnNFT.spec.ts b/chain-cli/chaincode-template/e2e/burnNFT.spec.ts index f3725505b6..a7bf7b8b4e 100644 --- a/chain-cli/chaincode-template/e2e/burnNFT.spec.ts +++ b/chain-cli/chaincode-template/e2e/burnNFT.spec.ts @@ -15,7 +15,6 @@ import { BurnTokensDto, FetchBalancesDto, - TokenBalance, TokenBurn, TokenClassKey, TokenInstanceKey, @@ -89,13 +88,10 @@ describe("NFT Burn scenario", () => { }); // When - const user1checkResponse = await client.assets.evaluateTransaction( - "FetchBalances", - user1TokenInstances, - TokenBalance - ); + const user1checkResponse = await client.assets.evaluateTransaction("FetchBalances", user1TokenInstances); + const page = user1checkResponse.Data as { results?: Array<{ instanceIds?: BigNumber[] }> }; // Then - expect((user1checkResponse.Data ?? [])[0].instanceIds).toEqual([]); + expect(page.results?.[0].instanceIds).toEqual([]); }); }); diff --git a/chain-cli/chaincode-template/e2e/setupTransferFees.ts b/chain-cli/chaincode-template/e2e/setupTransferFees.ts index 8b26e02a67..a18eb916b0 100644 --- a/chain-cli/chaincode-template/e2e/setupTransferFees.ts +++ b/chain-cli/chaincode-template/e2e/setupTransferFees.ts @@ -166,6 +166,7 @@ export async function setupTransferFees(client: AdminChainClients, users: ChainU "FetchBalances", user.sign(userGalaBalancesDto.withOperation(mockedOperation("GalaChainToken:FetchBalances"))) ); - expect(new BigNumber(userGalaBalanceResponse.Data?.[0]?.quantity ?? 0).isGreaterThan(0)).toBe(true); + const page = userGalaBalanceResponse.Data as { results?: Array<{ quantity?: BigNumber }> }; + expect(new BigNumber(page.results?.[0]?.quantity ?? 0).isGreaterThan(0)).toBe(true); } } diff --git a/chain-cli/chaincode-template/e2e/simpleNFT.spec.ts b/chain-cli/chaincode-template/e2e/simpleNFT.spec.ts index 54e4a9cb95..42a4d7e50c 100644 --- a/chain-cli/chaincode-template/e2e/simpleNFT.spec.ts +++ b/chain-cli/chaincode-template/e2e/simpleNFT.spec.ts @@ -161,10 +161,12 @@ describe("Simple NFT scenario", () => { // When const user1checkResponse = await client.assets.evaluateTransaction("FetchBalances", user1BalancesDto); const user2checkResponse = await client.assets.evaluateTransaction("FetchBalances", user2BalancesDto); + const user1Page = user1checkResponse.Data as { results?: Array<{ instanceIds?: BigNumber[] }> }; + const user2Page = user2checkResponse.Data as { results?: Array<{ instanceIds?: BigNumber[] }> }; // Then - expect(new BigNumber((user1checkResponse.Data ?? [])[0].instanceIds?.[0] ?? 0).isEqualTo(1)).toBe(true); - expect(new BigNumber((user2checkResponse.Data ?? [])[0].instanceIds?.[0] ?? 0).isEqualTo(2)).toBe(true); + expect(new BigNumber(user1Page.results?.[0].instanceIds?.[0] ?? 0).isEqualTo(1)).toBe(true); + expect(new BigNumber(user2Page.results?.[0].instanceIds?.[0] ?? 0).isEqualTo(2)).toBe(true); }); it("transfer NFT between users", async () => { diff --git a/chain-cli/chaincode-template/e2e/tokenOps.ts b/chain-cli/chaincode-template/e2e/tokenOps.ts index 78d9fa9bc5..9f81156aa3 100644 --- a/chain-cli/chaincode-template/e2e/tokenOps.ts +++ b/chain-cli/chaincode-template/e2e/tokenOps.ts @@ -17,11 +17,11 @@ import { ApplyRequestsDto, CreateTokenClassDto, FetchBalancesDto, + FetchBalancesWithPaginationResponse, GetMyProfileDto, GrantAllowanceDto, MintTokenDto, TokenAllowance, - TokenBalance, TokenClassKey, TokenInstance, TokenInstanceKey, @@ -148,28 +148,30 @@ export async function createTransferDto( * @param client - Chain client for blockchain queries * @param nftClassKey - Token class identifier * @param owner - User identity key to query balances for - * @returns Promise resolving to array of token instance IDs sorted numerically + * @returns Promise resolving to the token instance ids, sorted numerically * * @example * ```typescript * const instances = await fetchNFTInstances(client, nftClassKey, user.identityKey); - * console.log(instances); // ["1", "3", "7"] * ``` */ export async function fetchNFTInstances( client: ChainClient, nftClassKey: TokenClassKey, owner: string -): Promise { +): Promise { const dto = await createValidDTO(FetchBalancesDto, { ...instanceToPlain(nftClassKey), owner: asValidUserRef(owner) }); - const resp = await client.evaluateTransaction("FetchBalances", dto, TokenBalance); - expect(resp).toEqual(transactionSuccess([expect.anything()])); - - return (resp.Data ?? [])[0].instanceIds.sort((a, b) => a.comparedTo(b)); + const resp = await client.evaluateTransaction( + "FetchBalances", + dto, + FetchBalancesWithPaginationResponse + ); + const balance = resp.Data?.results?.[0] as unknown as { instanceIds?: BigNumber[] } | undefined; + return (balance?.instanceIds ?? []).sort((a, b) => a.comparedTo(b)); } /** diff --git a/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts b/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts index 004882396a..08599b71f2 100644 --- a/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts +++ b/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts @@ -34,6 +34,7 @@ import { FetchAllowancesResponse, FetchBalancesDto, FetchBalancesWithPaginationDto, + FetchBalancesWithPaginationResponse, FetchBalancesWithTokenMetadataResponse, FetchBurnsDto, FetchFeeScheduleDto, @@ -102,7 +103,7 @@ import { deleteAllowances, deleteTokenInstanceMetadata, fetchAllowancesWithPagination, - fetchBalances, + fetchBalancesWithPagination, fetchBalancesWithTokenMetadata, fetchBurns, fetchFeeSchedule, @@ -405,14 +406,21 @@ export default class GalaChainTokenContract extends GalaContract { @GalaTransaction({ type: EVALUATE, in: FetchBalancesDto, - out: { arrayOf: TokenBalance }, + out: FetchBalancesWithPaginationResponse, description: - "Deprecated: returns all matching balances without pagination. " + - "Use FetchBalancesWithTokenMetadata, which supports pagination, instead.", - deprecated: true + "Returns a page of balances. Provide limit and bookmark to page. " + + "When limit is omitted, the query uses the total results limit." }) - public async FetchBalances(ctx: GalaChainContext, dto: FetchBalancesDto): Promise { - return fetchBalances(ctx, { ...dto, owner: await resolveUserAlias(ctx, dto.owner ?? ctx.callingUser) }); + public async FetchBalances( + ctx: GalaChainContext, + dto: FetchBalancesDto + ): Promise { + return fetchBalancesWithPagination(ctx, { + ...dto, + owner: await resolveUserAlias(ctx, dto.owner ?? ctx.callingUser), + bookmark: dto.bookmark, + limit: dto.limit + }); } @GalaTransaction({ diff --git a/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap b/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap index 062c05efa8..f76493a1e9 100644 --- a/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap +++ b/chain-cli/chaincode-template/src/token/__snapshots__/api.spec.ts.snap @@ -3736,14 +3736,18 @@ The key is generated by the caller and should be unique for each DTO. You can us }, }, { - "deprecated": true, - "description": "Deprecated: returns all matching balances without pagination. Use FetchBalancesWithTokenMetadata, which supports pagination, instead. Transaction is read only (evaluate). Allowed roles: EVALUATE.", + "description": "Returns a page of balances. Provide limit and bookmark to page. When limit is omitted, the query uses the total results limit. Transaction is read only (evaluate). Allowed roles: EVALUATE.", "dtoSchema": { "description": "Contains parameters for fetching balances. Each parameter is optional.", "properties": { "additionalKey": { "description": "Token additionalKey.", }, + "bookmark": { + "description": "Page bookmark. If it is undefined, then the first page is returned.", + "minLength": 1, + "type": "string", + }, "category": { "description": "Token category. Optional, but required if type is provided.", "minLength": 1, @@ -3767,6 +3771,12 @@ The key is generated by the caller and should be unique for each DTO. You can us "minLength": 1, "type": "string", }, + "limit": { + "description": "Page size. When omitted, the query uses the total results limit. Maximum 10000.", + "maximum": 10000, + "minimum": 1, + "type": "number", + }, "multisig": { "description": "List of signatures for this DTO if there are multiple signers. If there are multiple signatures, 'signerAddress' is required, and it is not allowed to provide 'signature' or 'signerPublicKey' fields, and the signing scheme must be ETH.", "items": {}, @@ -3825,214 +3835,281 @@ The key is generated by the caller and should be unique for each DTO. You can us "responseSchema": { "properties": { "Data": { - "items": { - "properties": { - "additionalKey": { - "not": { - "type": "null", - }, - }, - "category": { - "minLength": 1, - "type": "string", - }, - "collection": { - "minLength": 1, - "type": "string", - }, - "inUseHolds": { - "description": "Deprecated. Legacy holds from the removed use/release feature; retained for old state.", - "items": { - "properties": { - "created": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", - "exclusiveMinimum": 0, - "type": "number", - }, - "createdBy": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", - }, - "expires": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", - "minimum": 0, - "type": "number", - }, - "instanceId": { - "description": "Number provided as a string.", - "minLength": 1, - "type": "string", - }, - "lockAuthority": { - "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", - "type": "string", - }, - "name": { - "minLength": 1, - "type": "string", - }, - "quantity": { - "description": "Number provided as a string.", - "type": "string", - }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", - "minimum": 0, - "type": "number", - }, - }, - "required": [ - "createdBy", - "instanceId", - "quantity", - "created", - "expires", - ], - "type": "object", - }, - "type": "array", - }, - "instanceIds": {}, - "limit": { - "description": "Optional owner quantity limit and hourly spend buckets (current hour plus the preceding 23). When quantity is set, it takes precedence over TokenClass.quantityLimit.", + "properties": { + "domain": { + "description": "EIP-712 signing domain. Present when the DTO is signed as typed data. The name is GalaChain on mainnet, GalaChainStage on stage, and GalaChainDev otherwise. Not a message field; ignored by personal_sign payloads.", + "type": "object", + }, + "dtoExpiresAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when the DTO expires. If this time is before the current time, the DTO is not valid.", + "type": "number", + }, + "dtoOperation": { + "description": "Full operation identifier that is called on chain with this DTO. The format is \`channelId_chaincodeId_methodName\`. It is required for multisig DTOs and for any DTO that carries a signature. ", + "minLength": 1, + "type": "string", + }, + "multisig": { + "description": "List of signatures for this DTO if there are multiple signers. If there are multiple signatures, 'signerAddress' is required, and it is not allowed to provide 'signature' or 'signerPublicKey' fields, and the signing scheme must be ETH.", + "items": {}, + "minItems": 2, + "type": "array", + }, + "nextPageBookmark": { + "description": "Next page bookmark.", + "type": "string", + }, + "prefix": { + "description": "Prefix for Metamask transaction signatures. Necessary to format payloads correctly to recover publicKey from web3 signatures.", + "minLength": 1, + "type": "string", + }, + "results": { + "description": "List of balances with token metadata.", + "items": { "properties": { - "hours": { - "description": "Spend per hourly bucket. Index is unixHour mod 24. Entering hour H zeros hours[H] (yesterday's same hour) and any skipped hours since lastHour, then new spend is added to hours[H].", - }, - "lastHour": { - "description": "Unix hour (floor(ms / 3600000)) of the last recorded spend.", - "minimum": 0, - "type": "number", - }, - "pendingAppliesAt": { - "description": "Unix epoch timestamp in milliseconds (ms) when pendingQuantity becomes effective.", - "minimum": 0, - "type": "number", + "additionalKey": { + "not": { + "type": "null", + }, }, - "pendingQuantity": { - "description": "Owner-requested quantity that is not yet effective. Applied at pendingAppliesAt when the requested limit is an increase versus the current effective limit. Number provided as a string.", + "category": { + "minLength": 1, "type": "string", }, - "quantity": { - "description": "Owner override for TokenClass.quantityLimit. When set, this value is the maximum that may be subtracted from this balance across the current hour and the preceding 23 hourly buckets. Number provided as a string.", + "collection": { + "minLength": 1, "type": "string", }, - }, - "type": "object", - }, - "lockedHolds": { - "items": { - "properties": { - "created": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", - "exclusiveMinimum": 0, - "type": "number", - }, - "createdBy": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", - }, - "expires": { - "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", - "minimum": 0, - "type": "number", - }, - "instanceId": { - "description": "Number provided as a string.", - "minLength": 1, - "type": "string", - }, - "lockAuthority": { - "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", - "type": "string", - }, - "name": { - "minLength": 1, - "type": "string", - }, - "quantity": { - "description": "Number provided as a string.", - "type": "string", + "inUseHolds": { + "description": "Deprecated. Legacy holds from the removed use/release feature; retained for old state.", + "items": { + "properties": { + "created": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", + "exclusiveMinimum": 0, + "type": "number", + }, + "createdBy": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "expires": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", + "minimum": 0, + "type": "number", + }, + "instanceId": { + "description": "Number provided as a string.", + "minLength": 1, + "type": "string", + }, + "lockAuthority": { + "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "quantity": { + "description": "Number provided as a string.", + "type": "string", + }, + "vestingPeriodStart": { + "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", + "minimum": 0, + "type": "number", + }, + }, + "required": [ + "createdBy", + "instanceId", + "quantity", + "created", + "expires", + ], + "type": "object", }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", - "minimum": 0, - "type": "number", + "type": "array", + }, + "instanceIds": {}, + "limit": { + "description": "Optional owner quantity limit and hourly spend buckets (current hour plus the preceding 23). When quantity is set, it takes precedence over TokenClass.quantityLimit.", + "properties": { + "hours": { + "description": "Spend per hourly bucket. Index is unixHour mod 24. Entering hour H zeros hours[H] (yesterday's same hour) and any skipped hours since lastHour, then new spend is added to hours[H].", + }, + "lastHour": { + "description": "Unix hour (floor(ms / 3600000)) of the last recorded spend.", + "minimum": 0, + "type": "number", + }, + "pendingAppliesAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when pendingQuantity becomes effective.", + "minimum": 0, + "type": "number", + }, + "pendingQuantity": { + "description": "Owner-requested quantity that is not yet effective. Applied at pendingAppliesAt when the requested limit is an increase versus the current effective limit. Number provided as a string.", + "type": "string", + }, + "quantity": { + "description": "Owner override for TokenClass.quantityLimit. When set, this value is the maximum that may be subtracted from this balance across the current hour and the preceding 23 hourly buckets. Number provided as a string.", + "type": "string", + }, }, + "type": "object", }, - "required": [ - "createdBy", - "instanceId", - "quantity", - "created", - "expires", - ], - "type": "object", - }, - "type": "array", - }, - "owner": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", - }, - "quantity": { - "description": "Number provided as a string.", - "type": "string", - }, - "targets": { - "description": "Optional destination restrictions for fungible subtract.", - "properties": { - "allowed": { - "description": "Allowed transfer destinations. undefined = no restriction. Empty array = frozen (no destinations). Non-empty = only those user aliases, stored sorted and unique. Burn is not a destination; empty list allows it as an edge case.", + "lockedHolds": { "items": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", + "properties": { + "created": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold was created (typically the transaction time).", + "exclusiveMinimum": 0, + "type": "number", + }, + "createdBy": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "expires": { + "description": "Unix epoch timestamp in milliseconds (ms) when the hold expires. 0 means no expiration.", + "minimum": 0, + "type": "number", + }, + "instanceId": { + "description": "Number provided as a string.", + "minLength": 1, + "type": "string", + }, + "lockAuthority": { + "description": "User who will be able to unlock token. If the value is missing, then token owner and lock creator can unlock in all cases token authority can unlock token.", + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "quantity": { + "description": "Number provided as a string.", + "type": "string", + }, + "vestingPeriodStart": { + "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart on vesting holds; undefined for non-vesting holds.", + "minimum": 0, + "type": "number", + }, + }, + "required": [ + "createdBy", + "instanceId", + "quantity", + "created", + "expires", + ], + "type": "object", }, - "maxItems": 32, "type": "array", }, - "pendingAllowAll": { - "description": "When true, pending change clears allowed (no restriction) at pendingAppliesAt.", - "type": "boolean", + "owner": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", }, - "pendingAllowed": { - "description": "Pending allowed list. Applied at pendingAppliesAt.", - "items": { - "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", - "type": "string", + "quantity": { + "description": "Number provided as a string.", + "type": "string", + }, + "targets": { + "description": "Optional destination restrictions for fungible subtract.", + "properties": { + "allowed": { + "description": "Allowed transfer destinations. undefined = no restriction. Empty array = frozen (no destinations). Non-empty = only those user aliases, stored sorted and unique. Burn is not a destination; empty list allows it as an edge case.", + "items": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "maxItems": 32, + "type": "array", + }, + "pendingAllowAll": { + "description": "When true, pending change clears allowed (no restriction) at pendingAppliesAt.", + "type": "boolean", + }, + "pendingAllowed": { + "description": "Pending allowed list. Applied at pendingAppliesAt.", + "items": { + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", + }, + "maxItems": 32, + "type": "array", + }, + "pendingAppliesAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when the pending target change becomes effective.", + "minimum": 0, + "type": "number", + }, }, - "maxItems": 32, - "type": "array", + "type": "object", }, - "pendingAppliesAt": { - "description": "Unix epoch timestamp in milliseconds (ms) when the pending target change becomes effective.", + "type": { + "minLength": 1, + "type": "string", + }, + "vestingPeriodStart": { + "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart. For vesting locks, this is the beginning of the vesting period.", "minimum": 0, "type": "number", }, }, + "required": [ + "owner", + "collection", + "category", + "type", + "additionalKey", + "quantity", + ], "type": "object", }, - "type": { - "minLength": 1, - "type": "string", - }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart. For vesting locks, this is the beginning of the vesting period.", - "minimum": 0, - "type": "number", - }, + "type": "array", + }, + "signature": { + "description": "Signature of the DTO signed with caller's private key to be verified with user's public key saved on chain. The 'signature' field is optional for DTO, but is required for a transaction to be executed on chain. +Please consult [GalaChain SDK documentation](https://github.com/GalaChain/sdk/blob/main/docs/authorization.md#signature-based-authorization) on how to create signatures.", + "minLength": 1, + "type": "string", + }, + "signerAddress": { + "description": "Address of the user who signed the DTO. Typically Ethereum address, or user alias.", + "minLength": 1, + "type": "string", + }, + "signerPublicKey": { + "description": "Public key of the user who signed the DTO.", + "minLength": 1, + "type": "string", + }, + "trace": { + "description": "OpenTelemetry trace context propagated from the caller for chaincode log correlation. Not part of the signed payload.", + "type": "object", + }, + "types": { + "description": "EIP-712 type definitions for the signed message. Present when the DTO is signed as typed data. Not a message field; ignored by personal_sign payloads.", + "type": "object", + }, + "uniqueKey": { + "description": "Unique key of the DTO. It is used to prevent double execution of the same transaction on chain. The key is saved on chain and checked before execution. If a DTO with already saved key is used in transaction, the transaction will fail with UniqueTransactionConflict error, which is mapped to HTTP 409 Conflict error. In case of the error, no changes are saved to chain state. +The key is generated by the caller and should be unique for each DTO. You can use \`nanoid\` library, UUID scheme, or any tool to generate unique string keys.", + "minLength": 1, + "type": "string", }, - "required": [ - "owner", - "collection", - "category", - "type", - "additionalKey", - "quantity", - ], - "type": "object", }, - "type": "array", + "required": [ + "results", + ], + "type": "object", }, "Message": { "type": "string", diff --git a/chain-connect/src/chainApis/TokenApi.ts b/chain-connect/src/chainApis/TokenApi.ts index e717f61241..2d28de0745 100644 --- a/chain-connect/src/chainApis/TokenApi.ts +++ b/chain-connect/src/chainApis/TokenApi.ts @@ -22,6 +22,7 @@ import { FetchAllowancesDto, FetchBalancesDto, FetchBalancesWithPaginationDto, + FetchBalancesWithPaginationResponse, FetchBurnsDto, FetchMintRequestsDto, FetchTokenClassesDto, @@ -260,12 +261,12 @@ export class TokenApi extends GalaChainBaseApi { } public FetchBalances(dto: FetchBalancesRequest) { - return this.submit({ + return this.submit({ method: "FetchBalances", payload: dto, url: this.chainCodeUrl, requestConstructor: FetchBalancesDto, - responseConstructor: TokenBalance + responseConstructor: FetchBalancesWithPaginationResponse }); } diff --git a/chaincode/src/__test__/GalaChainTokenContract.ts b/chaincode/src/__test__/GalaChainTokenContract.ts index 70706b8c9a..951a5365a3 100644 --- a/chaincode/src/__test__/GalaChainTokenContract.ts +++ b/chaincode/src/__test__/GalaChainTokenContract.ts @@ -37,6 +37,7 @@ import { FetchAllowancesResponse, FetchBalancesDto, FetchBalancesWithPaginationDto, + FetchBalancesWithPaginationResponse, FetchBalancesWithTokenMetadataResponse, FetchBurnsDto, FetchFeeScheduleDto, @@ -110,7 +111,7 @@ import { deleteAllowances, deleteTokenInstanceMetadata, fetchAllowancesWithPagination, - fetchBalances, + fetchBalancesWithPagination, fetchBalancesWithTokenMetadata, fetchBurns, fetchFeeSchedule, @@ -361,10 +362,18 @@ export default class GalaChainTokenContract extends GalaContract { @UnsignedEvaluate({ in: FetchBalancesDto, - out: { arrayOf: TokenBalance } + out: FetchBalancesWithPaginationResponse }) - public async FetchBalances(ctx: GalaChainContext, dto: FetchBalancesDto): Promise { - return fetchBalances(ctx, { ...dto, owner: await resolveUserAlias(ctx, dto.owner ?? ctx.callingUser) }); + public async FetchBalances( + ctx: GalaChainContext, + dto: FetchBalancesDto + ): Promise { + return fetchBalancesWithPagination(ctx, { + ...dto, + owner: await resolveUserAlias(ctx, dto.owner ?? ctx.callingUser), + bookmark: dto.bookmark, + limit: dto.limit + }); } @UnsignedEvaluate({ diff --git a/chaincode/src/balances/fetchBalances.spec.ts b/chaincode/src/balances/fetchBalances.spec.ts index ea842b1860..e14b65345d 100644 --- a/chaincode/src/balances/fetchBalances.spec.ts +++ b/chaincode/src/balances/fetchBalances.spec.ts @@ -12,7 +12,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FetchBalancesDto, TokenBalance, asValidUserRef, createValidDTO } from "@gala-chain/api"; +import { + FetchBalancesDto, + FetchBalancesWithPaginationResponse, + TokenBalance, + asValidUserRef, + createValidDTO +} from "@gala-chain/api"; import { fixture, transactionSuccess, users } from "@gala-chain/test"; import BigNumber from "bignumber.js"; @@ -33,7 +39,9 @@ it("should fetch balances by user alias", async () => { const result = await contract.FetchBalances(ctx, dto); // Then - expect(result).toEqual(transactionSuccess([balance])); + expect(result).toEqual( + transactionSuccess(new FetchBalancesWithPaginationResponse({ results: [balance], nextPageBookmark: "" })) + ); }); it("should fetch balances by empty signed dto", async () => { @@ -52,7 +60,9 @@ it("should fetch balances by empty signed dto", async () => { const result = await contract.FetchBalances(ctx, dto); // Then - expect(result).toEqual(transactionSuccess([balance])); + expect(result).toEqual( + transactionSuccess(new FetchBalancesWithPaginationResponse({ results: [balance], nextPageBookmark: "" })) + ); }); it("should fetch balances by any user ref", async () => { @@ -78,5 +88,33 @@ it("should fetch balances by any user ref", async () => { const results = await Promise.all(dtos.map((dto) => contract.FetchBalances(ctx, dto))); // Then - expect(results).toEqual(validUserRefs.map(() => transactionSuccess([balance]))); + expect(results).toEqual( + validUserRefs.map(() => + transactionSuccess( + new FetchBalancesWithPaginationResponse({ results: [balance], nextPageBookmark: "" }) + ) + ) + ); +}); + +it("passes a balance page size and bookmark through", async () => { + // Given + const user = users.testUser1; + const balance = new TokenBalance({ ...currency.tokenClassKey(), owner: user.identityKey }); + balance.addQuantity(new BigNumber(1000)); + const { contract, ctx } = fixture(GalaChainTokenContract).registeredUsers(user).savedState(balance); + const dto = await createValidDTO(FetchBalancesDto, { + owner: user.identityKey, + limit: 2, + bookmark: "page-2" + }); + const page = jest.spyOn(ctx.stub, "getStateByPartialCompositeKeyWithPagination"); + + // When + await contract.FetchBalances(ctx, dto); + + // Then + expect(page.mock.calls[0]?.[0]).toEqual(TokenBalance.INDEX_KEY); + expect(page.mock.calls[0]?.[2]).toEqual(2); + expect(page.mock.calls[0]?.[3]).toEqual("page-2"); }); From f45a01f41c0509d4d74b7f4e3f221f01f5117bfc Mon Sep 17 00:00:00 2001 From: Jakub Dzikowski Date: Thu, 29 Oct 2026 19:06:25 +0100 Subject: [PATCH 7/7] Fix: Format the paged balance read Co-authored-by: Cursor --- chain-cli/chaincode-template/e2e/tokenOps.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/chain-cli/chaincode-template/e2e/tokenOps.ts b/chain-cli/chaincode-template/e2e/tokenOps.ts index 9f81156aa3..d988d0168f 100644 --- a/chain-cli/chaincode-template/e2e/tokenOps.ts +++ b/chain-cli/chaincode-template/e2e/tokenOps.ts @@ -165,11 +165,7 @@ export async function fetchNFTInstances( owner: asValidUserRef(owner) }); - const resp = await client.evaluateTransaction( - "FetchBalances", - dto, - FetchBalancesWithPaginationResponse - ); + const resp = await client.evaluateTransaction("FetchBalances", dto, FetchBalancesWithPaginationResponse); const balance = resp.Data?.results?.[0] as unknown as { instanceIds?: BigNumber[] } | undefined; return (balance?.instanceIds ?? []).sort((a, b) => a.comparedTo(b)); }