diff --git a/chain-api/src/types/ChainObject.spec.ts b/chain-api/src/types/ChainObject.spec.ts index 1d8106260c..13348b72be 100644 --- a/chain-api/src/types/ChainObject.spec.ts +++ b/chain-api/src/types/ChainObject.spec.ts @@ -136,6 +136,17 @@ it("allows $ only as the first character of a string key part", () => { expect(() => ChainObject.getStringKeyFromParts(["a\u0000b"])).toThrow(/NUL/); }); +it("rejects an empty string key part", () => { + // "a" + "" + "b" would otherwise produce the same string as "a" + "$b" + expect(() => ChainObject.getStringKeyFromParts(["a", "", "b"])).toThrow(/index 1 is empty/); + expect(() => ChainObject.getStringKeyFromParts(["a", undefined as unknown as string, "b"])).toThrow( + /index 1 is empty/ + ); + expect(ChainObject.getStringKeyFromParts(["a", "$b"])).toEqual("a$$b"); + expect(() => ChainObject.getStringKeyFromParts(["a", ""])).toThrow(/index 1 is empty/); + expect(() => ChainObject.getStringKeyFromParts([""])).toThrow(/index 0 is empty/); +}); + it("rejects a NUL character in a joined query prefix", () => { expect(ChainObject.joinKeyParts(["a", "b_c", null])).toEqual("a\u0000b_c\u0000"); expect(() => ChainObject.joinKeyParts(["a\u0000b", "c"])).toThrow(/NUL/); diff --git a/chain-api/src/types/ChainObject.ts b/chain-api/src/types/ChainObject.ts index bb50f92dc5..68dbfeb066 100644 --- a/chain-api/src/types/ChainObject.ts +++ b/chain-api/src/types/ChainObject.ts @@ -170,8 +170,12 @@ export abstract class ChainObject { public static getStringKeyFromParts(parts: string[]): string { // "$" 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. + // An empty part is rejected, so "aaa" + "" + "bb" cannot produce "aaa$$bb" either. const texts = parts.map((part, index) => { const text = part === null || part === undefined ? "" : String(part); + if (text.length === 0) { + throw new InvalidCompositeKeyError(`string key part at index ${index} is empty`); + } if (text.includes(ChainObject.MIN_UNICODE_RUNE_VALUE)) { throw new InvalidCompositeKeyError(`string key part at index ${index} contains a NUL character`); } diff --git a/chain-api/src/types/token.ts b/chain-api/src/types/token.ts index 7fcea896e9..8a056c4988 100644 --- a/chain-api/src/types/token.ts +++ b/chain-api/src/types/token.ts @@ -375,9 +375,13 @@ export class UpdateTokenClassDto extends SubmitCallDTO { } @JSONSchema({ - description: "Contains parameters for fetching balances. Each parameter is optional." + description: + "Contains parameters for fetching balances. Each parameter is optional. " + + "The response contains at most 1000 balances. Use a paginated balance query for more." }) export class FetchBalancesDto extends ChainCallDTO { + public static readonly MAX_RESULTS = 1000; + @JSONSchema({ description: "Person who owns the balance. If the value is missing, chaincode caller is used." }) @@ -411,22 +415,6 @@ 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 258c6ee213..bf636c53cd 100644 --- a/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap +++ b/chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap @@ -5224,18 +5224,14 @@ The key is generated by the caller and should be unique for each DTO. You can us }, }, { - "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.", + "deprecated": true, + "description": "Deprecated: returns at most 1000 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.", + "description": "Contains parameters for fetching balances. Each parameter is optional. The response contains at most 1000 balances. Use a paginated balance query for more.", "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, @@ -5259,12 +5255,6 @@ 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": {}, @@ -5323,281 +5313,214 @@ The key is generated by the caller and should be unique for each DTO. You can us "responseSchema": { "properties": { "Data": { - "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": { - "additionalKey": { - "not": { - "type": "null", + "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", }, }, - "category": { - "minLength": 1, + "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": { + "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", }, - "collection": { - "minLength": 1, + "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", }, - "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": "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", }, - "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", - }, + "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", }, - "type": "object", }, - "lockedHolds": { + "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.", "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", + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", }, + "maxItems": 32, "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", + "pendingAllowAll": { + "description": "When true, pending change clears allowed (no restriction) at pendingAppliesAt.", + "type": "boolean", }, - "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", - }, + "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", }, - "type": "object", - }, - "type": { - "minLength": 1, - "type": "string", + "maxItems": 32, + "type": "array", }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart. For vesting locks, this is the beginning of the vesting period.", + "pendingAppliesAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when the pending target change becomes effective.", "minimum": 0, "type": "number", }, }, - "required": [ - "owner", - "collection", - "category", - "type", - "additionalKey", - "quantity", - ], "type": "object", }, - "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", + "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", }, - "required": [ - "results", - ], - "type": "object", + "type": "array", }, "Message": { "type": "string", diff --git a/chain-cli/chaincode-template/e2e/burnNFT.spec.ts b/chain-cli/chaincode-template/e2e/burnNFT.spec.ts index a7bf7b8b4e..f3725505b6 100644 --- a/chain-cli/chaincode-template/e2e/burnNFT.spec.ts +++ b/chain-cli/chaincode-template/e2e/burnNFT.spec.ts @@ -15,6 +15,7 @@ import { BurnTokensDto, FetchBalancesDto, + TokenBalance, TokenBurn, TokenClassKey, TokenInstanceKey, @@ -88,10 +89,13 @@ describe("NFT Burn scenario", () => { }); // When - const user1checkResponse = await client.assets.evaluateTransaction("FetchBalances", user1TokenInstances); - const page = user1checkResponse.Data as { results?: Array<{ instanceIds?: BigNumber[] }> }; + const user1checkResponse = await client.assets.evaluateTransaction( + "FetchBalances", + user1TokenInstances, + TokenBalance + ); // Then - expect(page.results?.[0].instanceIds).toEqual([]); + expect((user1checkResponse.Data ?? [])[0].instanceIds).toEqual([]); }); }); diff --git a/chain-cli/chaincode-template/e2e/setupTransferFees.ts b/chain-cli/chaincode-template/e2e/setupTransferFees.ts index a18eb916b0..8b26e02a67 100644 --- a/chain-cli/chaincode-template/e2e/setupTransferFees.ts +++ b/chain-cli/chaincode-template/e2e/setupTransferFees.ts @@ -166,7 +166,6 @@ export async function setupTransferFees(client: AdminChainClients, users: ChainU "FetchBalances", user.sign(userGalaBalancesDto.withOperation(mockedOperation("GalaChainToken:FetchBalances"))) ); - const page = userGalaBalanceResponse.Data as { results?: Array<{ quantity?: BigNumber }> }; - expect(new BigNumber(page.results?.[0]?.quantity ?? 0).isGreaterThan(0)).toBe(true); + expect(new BigNumber(userGalaBalanceResponse.Data?.[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 42a4d7e50c..54e4a9cb95 100644 --- a/chain-cli/chaincode-template/e2e/simpleNFT.spec.ts +++ b/chain-cli/chaincode-template/e2e/simpleNFT.spec.ts @@ -161,12 +161,10 @@ 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(user1Page.results?.[0].instanceIds?.[0] ?? 0).isEqualTo(1)).toBe(true); - expect(new BigNumber(user2Page.results?.[0].instanceIds?.[0] ?? 0).isEqualTo(2)).toBe(true); + 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); }); 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 d988d0168f..78d9fa9bc5 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,26 +148,28 @@ 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 the token instance ids, sorted numerically + * @returns Promise resolving to array of 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, FetchBalancesWithPaginationResponse); - const balance = resp.Data?.results?.[0] as unknown as { instanceIds?: BigNumber[] } | undefined; - return (balance?.instanceIds ?? []).sort((a, b) => a.comparedTo(b)); + 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)); } /** diff --git a/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts b/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts index 08599b71f2..e5c6b9374e 100644 --- a/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts +++ b/chain-cli/chaincode-template/src/token/GalaChainTokenContract.ts @@ -34,7 +34,6 @@ import { FetchAllowancesResponse, FetchBalancesDto, FetchBalancesWithPaginationDto, - FetchBalancesWithPaginationResponse, FetchBalancesWithTokenMetadataResponse, FetchBurnsDto, FetchFeeScheduleDto, @@ -406,21 +405,20 @@ export default class GalaChainTokenContract extends GalaContract { @GalaTransaction({ type: EVALUATE, in: FetchBalancesDto, - out: FetchBalancesWithPaginationResponse, + out: { arrayOf: TokenBalance }, description: - "Returns a page of balances. Provide limit and bookmark to page. " + - "When limit is omitted, the query uses the total results limit." + `Deprecated: returns at most ${FetchBalancesDto.MAX_RESULTS} matching balances, without pagination. ` + + "Use FetchBalancesWithTokenMetadata, which supports pagination, instead.", + deprecated: true }) - public async FetchBalances( - ctx: GalaChainContext, - dto: FetchBalancesDto - ): Promise { - return fetchBalancesWithPagination(ctx, { + public async FetchBalances(ctx: GalaChainContext, dto: FetchBalancesDto): Promise { + const page = await fetchBalancesWithPagination(ctx, { ...dto, owner: await resolveUserAlias(ctx, dto.owner ?? ctx.callingUser), - bookmark: dto.bookmark, - limit: dto.limit + bookmark: undefined, + limit: FetchBalancesDto.MAX_RESULTS }); + return page.results.slice(0, FetchBalancesDto.MAX_RESULTS); } @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 f76493a1e9..d5a13a330d 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,18 +3736,14 @@ The key is generated by the caller and should be unique for each DTO. You can us }, }, { - "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.", + "deprecated": true, + "description": "Deprecated: returns at most 1000 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.", + "description": "Contains parameters for fetching balances. Each parameter is optional. The response contains at most 1000 balances. Use a paginated balance query for more.", "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, @@ -3771,12 +3767,6 @@ 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": {}, @@ -3835,281 +3825,214 @@ The key is generated by the caller and should be unique for each DTO. You can us "responseSchema": { "properties": { "Data": { - "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": { - "additionalKey": { - "not": { - "type": "null", + "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", }, }, - "category": { - "minLength": 1, + "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": { + "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", }, - "collection": { - "minLength": 1, + "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", }, - "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": "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", }, - "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", - }, + "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", }, - "type": "object", }, - "lockedHolds": { + "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.", "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", + "description": "Allowed value is string following the format of 'client|', or 'eth|', or valid system-level username.", + "type": "string", }, + "maxItems": 32, "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", + "pendingAllowAll": { + "description": "When true, pending change clears allowed (no restriction) at pendingAppliesAt.", + "type": "boolean", }, - "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", - }, + "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", }, - "type": "object", - }, - "type": { - "minLength": 1, - "type": "string", + "maxItems": 32, + "type": "array", }, - "vestingPeriodStart": { - "description": "Unix epoch timestamp in milliseconds (ms) for vestingPeriodStart. For vesting locks, this is the beginning of the vesting period.", + "pendingAppliesAt": { + "description": "Unix epoch timestamp in milliseconds (ms) when the pending target change becomes effective.", "minimum": 0, "type": "number", }, }, - "required": [ - "owner", - "collection", - "category", - "type", - "additionalKey", - "quantity", - ], "type": "object", }, - "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", + "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", }, - "required": [ - "results", - ], - "type": "object", + "type": "array", }, "Message": { "type": "string", diff --git a/chain-connect/src/chainApis/TokenApi.ts b/chain-connect/src/chainApis/TokenApi.ts index 2d28de0745..e717f61241 100644 --- a/chain-connect/src/chainApis/TokenApi.ts +++ b/chain-connect/src/chainApis/TokenApi.ts @@ -22,7 +22,6 @@ import { FetchAllowancesDto, FetchBalancesDto, FetchBalancesWithPaginationDto, - FetchBalancesWithPaginationResponse, FetchBurnsDto, FetchMintRequestsDto, FetchTokenClassesDto, @@ -261,12 +260,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: FetchBalancesWithPaginationResponse + responseConstructor: TokenBalance }); } diff --git a/chaincode/src/__test__/GalaChainTokenContract.ts b/chaincode/src/__test__/GalaChainTokenContract.ts index 951a5365a3..f75ece9265 100644 --- a/chaincode/src/__test__/GalaChainTokenContract.ts +++ b/chaincode/src/__test__/GalaChainTokenContract.ts @@ -37,7 +37,6 @@ import { FetchAllowancesResponse, FetchBalancesDto, FetchBalancesWithPaginationDto, - FetchBalancesWithPaginationResponse, FetchBalancesWithTokenMetadataResponse, FetchBurnsDto, FetchFeeScheduleDto, @@ -362,18 +361,16 @@ export default class GalaChainTokenContract extends GalaContract { @UnsignedEvaluate({ in: FetchBalancesDto, - out: FetchBalancesWithPaginationResponse + out: { arrayOf: TokenBalance } }) - public async FetchBalances( - ctx: GalaChainContext, - dto: FetchBalancesDto - ): Promise { - return fetchBalancesWithPagination(ctx, { + public async FetchBalances(ctx: GalaChainContext, dto: FetchBalancesDto): Promise { + const page = await fetchBalancesWithPagination(ctx, { ...dto, owner: await resolveUserAlias(ctx, dto.owner ?? ctx.callingUser), - bookmark: dto.bookmark, - limit: dto.limit + bookmark: undefined, + limit: FetchBalancesDto.MAX_RESULTS }); + return page.results.slice(0, FetchBalancesDto.MAX_RESULTS); } @UnsignedEvaluate({ diff --git a/chaincode/src/balances/fetchBalances.spec.ts b/chaincode/src/balances/fetchBalances.spec.ts index e14b65345d..80ad3de40d 100644 --- a/chaincode/src/balances/fetchBalances.spec.ts +++ b/chaincode/src/balances/fetchBalances.spec.ts @@ -12,13 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - FetchBalancesDto, - FetchBalancesWithPaginationResponse, - TokenBalance, - asValidUserRef, - createValidDTO -} from "@gala-chain/api"; +import { FetchBalancesDto, TokenBalance, asValidUserRef, createValidDTO } from "@gala-chain/api"; import { fixture, transactionSuccess, users } from "@gala-chain/test"; import BigNumber from "bignumber.js"; @@ -39,9 +33,7 @@ it("should fetch balances by user alias", async () => { const result = await contract.FetchBalances(ctx, dto); // Then - expect(result).toEqual( - transactionSuccess(new FetchBalancesWithPaginationResponse({ results: [balance], nextPageBookmark: "" })) - ); + expect(result).toEqual(transactionSuccess([balance])); }); it("should fetch balances by empty signed dto", async () => { @@ -60,9 +52,7 @@ it("should fetch balances by empty signed dto", async () => { const result = await contract.FetchBalances(ctx, dto); // Then - expect(result).toEqual( - transactionSuccess(new FetchBalancesWithPaginationResponse({ results: [balance], nextPageBookmark: "" })) - ); + expect(result).toEqual(transactionSuccess([balance])); }); it("should fetch balances by any user ref", async () => { @@ -88,33 +78,31 @@ 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( - new FetchBalancesWithPaginationResponse({ results: [balance], nextPageBookmark: "" }) - ) - ) - ); + expect(results).toEqual(validUserRefs.map(() => transactionSuccess([balance]))); }); -it("passes a balance page size and bookmark through", async () => { +it("should return at most the maximum number of balances", 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 balances = Array.from({ length: FetchBalancesDto.MAX_RESULTS + 1 }, (_, i) => { + const balance = new TokenBalance({ + ...currency.tokenClassKey(), + type: `Type${i}`, + owner: user.identityKey + }); + balance.addQuantity(new BigNumber(1)); + return balance; }); - const page = jest.spyOn(ctx.stub, "getStateByPartialCompositeKeyWithPagination"); + + const { contract, ctx } = fixture(GalaChainTokenContract) + .registeredUsers(user) + .savedState(...balances); + + const dto = await createValidDTO(FetchBalancesDto, { owner: user.identityKey }); // When - await contract.FetchBalances(ctx, dto); + const result = 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"); + expect(result.Data).toHaveLength(FetchBalancesDto.MAX_RESULTS); }); diff --git a/chaincode/src/token/createTokenClass.spec.ts b/chaincode/src/token/createTokenClass.spec.ts index 9ae50fbab3..98bbad8db0 100644 --- a/chaincode/src/token/createTokenClass.spec.ts +++ b/chaincode/src/token/createTokenClass.spec.ts @@ -106,3 +106,44 @@ it("should CreateTokenClass when max supply is omitted", async () => { }); expect(getWrites()).toEqual(writesMap(expectedInstance, expectedClass)); }); + +it.each([ + ["at the start of", "$Currency", "Success"], + ["inside", "Curr$ency", "Error"] +])("should handle $ %s a token class key field", async (_where, category, outcome) => { + // Given + const { ctx, contract, getWrites } = fixture(GalaChainTokenContract) + .caClientIdentity("test-admin", "CuratorOrg") + .registeredUsers(users.admin); + + const tokenClassKey = currency.tokenClassKey(); + tokenClassKey.category = category; + + const dto: CreateTokenClassDto = await createValidSubmitDTO(CreateTokenClassDto, { + tokenClass: tokenClassKey, + maxCapacity: new BigNumber("1000000000"), + name: "TESTCURRENCY", + description: "A mock currency for automated tests.", + decimals: 4, + network: "GC", + image: "http://app.gala.games/some-test-image-url", + symbol: "AUTOTESTCOIN", + isNonFungible: false, + authorities: [users.admin.identityKey] + }) + .withOperation("asset-channel_basic-asset_GalaChainToken:CreateTokenClass") + .signed(users.admin.privateKey); + + // When + const response = await contract.CreateTokenClass(ctx, dto); + + // Then + if (outcome === "Success") { + expect(response).toEqual(GalaChainResponse.Success(tokenClassKey)); + } else { + expect(response).toEqual( + expect.objectContaining({ Status: 0, Message: expect.stringContaining("only as its first character") }) + ); + expect(getWrites()).toEqual({}); + } +}); diff --git a/chaincode/src/token/createTokenClass.ts b/chaincode/src/token/createTokenClass.ts index 408760e102..49f04e55a8 100644 --- a/chaincode/src/token/createTokenClass.ts +++ b/chaincode/src/token/createTokenClass.ts @@ -12,7 +12,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TokenClass, TokenClassKey, TokenInstance, UserAlias, createValidChainObject } from "@gala-chain/api"; +import { + ChainObject, + TokenClass, + TokenClassKey, + TokenInstance, + UserAlias, + ValidationFailedError, + createValidChainObject +} from "@gala-chain/api"; import BigNumber from "bignumber.js"; import { GalaChainContext } from "../types"; @@ -49,6 +57,15 @@ export async function createTokenClass( throw new NftDecimalError(params.decimals); } + // String keys of a token class allow "$" only as the first character of a key field. + for (const field of ["collection", "category", "type", "additionalKey"] as const) { + if ((params.tokenClass[field] ?? "").indexOf(ChainObject.ID_SPLIT_CHAR) > 0) { + throw new ValidationFailedError( + `Token class ${field} may contain ${ChainObject.ID_SPLIT_CHAR} only as its first character` + ); + } + } + const newToken: TokenClass = await createValidChainObject(TokenClass, { network: params.network, collection: params.tokenClass.collection, diff --git a/docs/exchange-integration.md b/docs/exchange-integration.md index 6404d164a9..64ade2ec5e 100644 --- a/docs/exchange-integration.md +++ b/docs/exchange-integration.md @@ -76,6 +76,8 @@ Yes, the API is **limited to 20 requests per minute**. Custom rate limits can be Use the **FetchBalances API**: [https://gateway-mainnet.galachain.com/api/asset/token-contract/FetchBalances](https://gateway-mainnet.galachain.com/api/asset/token-contract/FetchBalances) +A single `FetchBalances` response contains at most 1000 balances. To get more, narrow the query with token class fields (`collection`, `category`, `type`, `additionalKey`) or use a paginated balance query. + ### Does GalaChain provide transaction IDs (txIDs)? Yes, GalaChain transactions are each assigned a unique transaction ID. Each block can contain many transactions. Each transaction can contain many reads and writes. Because GalaChain is built on Hyperledger Fabric, more technical details can be found in their documentation: [https://hyperledger-fabric.readthedocs.io/en/release-2.4/txflow.html](https://hyperledger-fabric.readthedocs.io/en/release-2.4/txflow.html) , [https://hyperledger-fabric.readthedocs.io/en/release-2.4/readwrite.html](https://hyperledger-fabric.readthedocs.io/en/release-2.4/readwrite.html)