Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
421 changes: 421 additions & 0 deletions chain-api/src/types/TokenInstanceMetadata.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions chain-api/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export * from "./logger";
export * from "./PublicKey";
export * from "./UserProfile";
export * from "./TokenInstance";
export * from "./TokenInstanceMetadata";
export * from "./TokenClass";
export * from "./token";
export * from "./common";
Expand Down
26 changes: 26 additions & 0 deletions chain-api/src/validators/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,32 @@ export function BigNumberIsInteger(validationOptions?: ValidationOptions) {
};
}

export function IsStringOrNumber(maxStringLength?: number, validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: "IsStringOrNumber",
target: object.constructor,
propertyName,
constraints: [maxStringLength],
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
const [maxLength] = args.constraints;
if (typeof value === "string") {
return maxLength === undefined || value.length <= maxLength;
}
return typeof value === "number" && Number.isFinite(value);
},
defaultMessage(args: ValidationArguments) {
const [maxLength] = args.constraints;
const lengthConstraint = maxLength === undefined ? "" : ` no longer than ${maxLength} characters`;
return `${args.property} must be a finite number or a string${lengthConstraint}`;
}
}
});
};
}

/**
* @description Decorator that conditionally includes/excludes a property during serialization/deserialization
* based on a condition function that evaluates other properties of the object.
Expand Down
1,815 changes: 1,706 additions & 109 deletions chain-cli/chaincode-template/e2e/__snapshots__/api.spec.ts.snap

Large diffs are not rendered by default.

264 changes: 264 additions & 0 deletions chain-cli/chaincode-template/e2e/tokenInstanceMetadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/*
* 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 {
AllowanceType,
ChainUser,
CreateTokenClassDto,
DeleteTokenInstanceMetadataDto,
FetchTokenInstanceMetadataDto,
GrantAllowanceDto,
MintTokenDto,
SetTokenInstanceMetadataDto,
TokenClassKey,
TokenInstance,
TokenInstanceKey,
TokenInstanceMetadata,
TokenInstanceMetadataAttribute,
TokenInstanceMetadataCustomField,
createValidDTO,
createValidSubmitDTO
} from "@gala-chain/api";
import {
AdminChainClients,
TestClients,
randomize,
transactionErrorCode,
transactionSuccess
} from "@gala-chain/test";
import BigNumber from "bignumber.js";
import { plainToInstance } from "class-transformer";

jest.setTimeout(30000);

describe("Token instance metadata scenario", () => {
let client: AdminChainClients;
let user1: ChainUser;
let user2: ChainUser;

const project = "project-alpha";

beforeAll(async () => {
client = await TestClients.createForAdmin();
user1 = ChainUser.withRandomKeys();
user2 = ChainUser.withRandomKeys();
});

afterAll(async () => {
await client.disconnect();
});

const nftClassKey: TokenClassKey = plainToInstance(TokenClassKey, {
collection: randomize("NFT"),
category: "Weapon",
type: "Axe",
additionalKey: "none"
});

const nftInstanceKey = () => TokenInstanceKey.nftKey(nftClassKey, 1);

it("Curator should create NFT Class and mint an instance", async () => {
// Given
const createClassDto = await createValidSubmitDTO(CreateTokenClassDto, {
decimals: 0,
tokenClass: nftClassKey,
name: nftClassKey.collection,
symbol: nftClassKey.collection.slice(0, 20),
description: "This is a test description!",
isNonFungible: true,
image: "https://app.gala.games/_nuxt/img/gala-logo_horizontal_white.8b0409c.png",
maxSupply: new BigNumber(10)
});

const allowanceDto = await createValidSubmitDTO(GrantAllowanceDto, {
tokenInstance: TokenInstanceKey.nftKey(nftClassKey, TokenInstance.FUNGIBLE_TOKEN_INSTANCE).toQueryKey(),
allowanceType: AllowanceType.Mint,
quantities: [{ user: user1.identityKey, quantity: new BigNumber(1) }],
uses: new BigNumber(10)
});

const mintDto = await createValidSubmitDTO(MintTokenDto, {
owner: user1.identityKey,
tokenClass: nftClassKey,
quantity: new BigNumber(1)
});

// When
const createClassResponse = await client.assets.submitTransaction<TokenClassKey>(
"CreateTokenClass",
createClassDto.signed(client.assets.privateKey),
TokenClassKey
);
const allowanceResponse = await client.assets.submitTransaction(
"GrantAllowance",
allowanceDto.signed(client.assets.privateKey)
);
const mintResponse = await client.assets.submitTransaction("MintToken", mintDto.signed(user1.privateKey));

// Then
expect(createClassResponse).toEqual(transactionSuccess(nftClassKey));
expect(allowanceResponse).toEqual(transactionSuccess());
expect(mintResponse).toEqual(transactionSuccess());
});

it("User should set token instance metadata for their project", async () => {
// Given
const setDto = await createValidSubmitDTO(SetTokenInstanceMetadataDto, {
tokenInstance: nftInstanceKey(),
project,
name: "Legendary Axe #1",
description: "An axe of legend.",
image: "https://app.gala.games/test-image-placeholder-url.png",
attributes: [
plainToInstance(TokenInstanceMetadataAttribute, {
trait_type: "Sharpness",
value: 10,
display_type: "number"
})
],
customFields: [plainToInstance(TokenInstanceMetadataCustomField, { key: "gameId", value: "axe-001" })]
});

// When
const response = await client.assets.submitTransaction<TokenInstanceMetadata>(
"SetTokenInstanceMetadata",
setDto.signed(user1.privateKey),
TokenInstanceMetadata
);

// Then
expect(response).toEqual(
transactionSuccess(
expect.objectContaining({
project,
name: "Legendary Axe #1",
attributes: [expect.objectContaining({ trait_type: "Sharpness", value: 10 })]
})
)
);
});

it("Another user should not modify the project's metadata", async () => {
// Given
const setDto = await createValidSubmitDTO(SetTokenInstanceMetadataDto, {
tokenInstance: nftInstanceKey(),
project,
name: "Hijacked Axe"
});

// When
const response = await client.assets.submitTransaction(
"SetTokenInstanceMetadata",
setDto.signed(user2.privateKey)
);

// Then
expect(response).toEqual(transactionErrorCode(403));
});

it("Another user should set metadata for a different project on the same instance", async () => {
// Given
const setDto = await createValidSubmitDTO(SetTokenInstanceMetadataDto, {
tokenInstance: nftInstanceKey(),
project: "project-beta",
name: "Beta View of the Axe"
});

// When
const response = await client.assets.submitTransaction<TokenInstanceMetadata>(
"SetTokenInstanceMetadata",
setDto.signed(user2.privateKey),
TokenInstanceMetadata
);

// Then
expect(response).toEqual(
transactionSuccess(expect.objectContaining({ project: "project-beta", name: "Beta View of the Axe" }))
);
});

it("Anyone should fetch metadata of all projects for the instance", async () => {
// Given
const fetchDto = await createValidDTO(FetchTokenInstanceMetadataDto, {
tokenInstance: nftInstanceKey()
});

// When
const response = await client.assets.evaluateTransaction(
"FetchTokenInstanceMetadata",
fetchDto,
TokenInstanceMetadata
);

// Then
expect(response).toEqual(
transactionSuccess(
expect.arrayContaining([
expect.objectContaining({ project, name: "Legendary Axe #1" }),
expect.objectContaining({ project: "project-beta" })
])
)
);
});

it("Project owner should replace metadata as a full document", async () => {
// Given
const replaceDto = await createValidSubmitDTO(SetTokenInstanceMetadataDto, {
tokenInstance: nftInstanceKey(),
project,
name: "Renamed Axe"
});

// When
const response = await client.assets.submitTransaction<TokenInstanceMetadata>(
"SetTokenInstanceMetadata",
replaceDto.signed(user1.privateKey),
TokenInstanceMetadata
);

// Then
const metadata = response.Data as TokenInstanceMetadata;
expect(response).toEqual(transactionSuccess(expect.objectContaining({ name: "Renamed Axe" })));
expect(metadata.description).toBeUndefined();
expect(metadata.attributes).toBeUndefined();
});

it("Project owner should delete metadata, and project fetch should fail afterwards", async () => {
// Given
const deleteDto = await createValidSubmitDTO(DeleteTokenInstanceMetadataDto, {
tokenInstance: nftInstanceKey(),
project
});

const fetchDto = await createValidDTO(FetchTokenInstanceMetadataDto, {
tokenInstance: nftInstanceKey(),
project
});

// When
const deleteResponse = await client.assets.submitTransaction(
"DeleteTokenInstanceMetadata",
deleteDto.signed(user1.privateKey)
);
const fetchResponse = await client.assets.evaluateTransaction(
"FetchTokenInstanceMetadata",
fetchDto,
TokenInstanceMetadata
);

// Then
expect(deleteResponse).toEqual(transactionSuccess());
expect(fetchResponse).toEqual(transactionErrorCode(404));
});
});
Loading
Loading