diff --git a/.env b/.env new file mode 100644 index 000000000..c284515e9 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +NX_NON_NATIVE_HASHER=true \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..f3bd2c654 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules +lib +coverage \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..84f6386c3 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,65 @@ +{ + "root": true, + "ignorePatterns": ["lib/*", "node_modules/*"], + "plugins": ["@nx", "@typescript-eslint", "prettier"], + "env": { + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "prettier/prettier": "error", + "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/ban-types": [ + "error", + { + "types": { + "{}": false + }, + "extendDefaults": true + } + ] + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": { + "@nx/enforce-module-boundaries": [ + "error", + { + "enforceBuildableLibDependency": true, + "allow": [], + "depConstraints": [ + { + "sourceTag": "*", + "onlyDependOnLibsWithTags": ["*"] + } + ] + } + ] + } + }, + { + "files": ["*.ts", "*.tsx"], + "extends": ["plugin:@nx/typescript"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "extends": ["plugin:@nx/javascript"], + "rules": {} + }, + { + "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], + "env": { + "jest": true + }, + "rules": {} + } + ] +} diff --git a/.github/workflows/publish-page.yml b/.github/workflows/publish-page.yml new file mode 100644 index 000000000..24e0f2f4e --- /dev/null +++ b/.github/workflows/publish-page.yml @@ -0,0 +1,50 @@ +name: Publish page +on: + push: + tags: + - '*' + workflow_dispatch: + +env: + PAGES_BRANCH: gh-pages + HTTPS_REMOTE: "https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}" +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install google-chrome + run: | + wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + sudo dpkg -i google-chrome*.deb + - name: Install python3 and pip + run: | + sudo apt-get install -y python3 + sudo apt-get install -y python3-pip python3-pillow python3-cffi python3-brotli gcc musl-dev python3-dev + - name: Setup Node.js + uses: actions/setup-node@v3 + - name: Install mermaid-cli + run: npm install -g @mermaid-js/mermaid-cli + - name: Install pip packages + run: pip3 install mkdocs==1.5.3 mkdocs-material==9.4.8 mike==1.1.2 beautifulsoup4==4.9.3 setuptools==58.2.0 + - name: Git config + run: | + git config --global user.email "${GITHUB_ACTOR}" + git config --global user.name "${GITHUB_ACTOR}@gala.games.com" + - name: Clone mkdocs-with-pdf fixed branch and install + run: | + git clone -b render-mermaid-png https://github.com/Fuerback/mkdocs-with-pdf.git + cd mkdocs-with-pdf + sudo python3 setup.py install + cd .. + - name: Mkdocs build + run: | + mkdocs build + - name: Push a new version of the docs + run: | + git fetch origin $PAGES_BRANCH && git -b checkout $PAGES_BRANCH origin/$PAGES_BRANCH || git checkout $PAGES_BRANCH || echo "Pages branch not deployed yet." + git checkout $GITHUB_SHA + mike deploy --rebase --prefix docs -r $HTTPS_REMOTE -p -b $PAGES_BRANCH -u ${GITHUB_REF#refs/tags/} latest + mike set-default --rebase --prefix docs -r $HTTPS_REMOTE -p -b $PAGES_BRANCH latest + git checkout $PAGES_BRANCH -- docs/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..eabea3509 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +dist +tmp +/out-tsc +public/ +lib + +# dependencies +node_modules + +# IDEs and editors +/.idea +/.metals +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db +.npmrc +/.nx \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..3c032078a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..d155fdbd5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Add files here to ignore them from prettier formatting +/dist +/coverage +/.nx/cache \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..81b62e98b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": false, + "printWidth": 110, + "tabWidth": 2, + "trailingComma": "none", + "importOrder": ["^@oclif/(.*)$", "^dotenv/(.*)$", "^dd-trace(.*)$", "tracer(.*)$", "", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrderParserPlugins": ["typescript", "decorators-legacy"] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..4b6e5c09c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# GalaChain SDK Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +jdzikowski@gala.games. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/LICENSE b/LICENSE index 261eeb9e9..fc1f2ece5 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright (c) Gala Games Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..499f6ca5e --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# GalaChain SDK + +Welcome to developing with GalaChain! +GalaChain is a layer 1 blockchain designed to be the foundation of Web3 Gaming, Entertainment and more. + +## Features + +- Utility libraries to allow seamless development of chaincodes +- Local development environment with hot code reload and local block browser +- Easy start with chaincode template +- Integration with GalaChain + +Read more about [GalaChain](docs/galachain.md). + +## Working with GalaChain + +- [**Getting started guide**](docs/getting-started.md) +- [Chaincode development](docs/chaincode-development.md) +- [CLI reference](chain-cli/README.md) + +## Deploying chaincode to GalaChain + +- [Chaincode deployment](docs/chaincode-deployment.md) + +## Reference documentation +- [`chain-api`](docs/chain-api-docs/exports.md) - Common types, DTOs (Data Transfer Objects), APIs, signatures, and utils for GalaChain. +- [`chain-client`](docs/chain-client-docs/exports.md) - GalaChain client library +- [`chain-test`](docs/chain-test-docs/exports.md) - Unit testing and integration testing for GalaChain +- [`chaincode` framework](docs/chaincode-docs/exports.md) - Framework for building chaincodes on GalaChain + +## Documentation in PDF format + +- [PDF file](docs/pdf/sdk-documentation.pdf) diff --git a/TESTIMONIALS.md b/TESTIMONIALS.md new file mode 100644 index 000000000..8efdae545 --- /dev/null +++ b/TESTIMONIALS.md @@ -0,0 +1,39 @@ +# GalaChain SDK testimonials + +**Thanks to Our Team:** + +A special thanks to the talented individuals from [Gala Games](https://www.gala.com/) Chain Team who laid the foundation of the SDK we are publishing. +The SDK, had been developed internally over the past two years before its public release in January 2024, and used in production for over a year. +Now it has reached new heights, thanks to your commitment. + +Members of Chain Team at Gala Games in alphabetical order: + +- Elijah Baldwin [@befitsandpiper](https://github.com/befitsandpiper) +- Piotr Buda [@pbuda](https://github.com/pbuda) +- Chris Chuter [@cchuter](https://github.com/cchuter) +- Jakub Dzikowski [@dzikowski](https://github.com/dzikowski) +- Jeff Eganhouse [@Jehosephat](https://github.com/Jehosephat) +- Felipe Fuerback [@Fuerback](https://github.com/Fuerback) +- Mike Graham [@sentientforest](https://github.com/sentientforest) +- Dustin Jorge [@dustinmj](https://github.com/dustinmj) +- Jonas Michel [@jonasrmichel](https://github.com/jonasrmichel) +- MichaƂ Mital [@m-mital](https://github.com/m-mital) +- Rhiannon Nichols [@RhiannonNichols](https://github.com/RhiannonNichols) +- Umegbewe "Great" Nwebedu [@umegbewe](https://github.com/umegbewe) +- Todd Rasband [@Rasbandit](https://github.com/Rasbandit) +- Ivan Vodzich [@Ivan456](https://github.com/Ivan456) + +Past Chain Team members: + +- Chris Freeland [@cruxone](https://github.com/cruxone) +- Piotr Hejwowski [@Hejwo](https://github.com/Hejwo) +- Greg Horejsi [@BitFlippedCelt](https://github.com/BitFlippedCelt) +- Derek Jarvis [@DerekJarvis](https://github.com/DerekJarvis) +- Stephen Nichols [@snichols](https://github.com/snichols) + +If by any chance we have unintentionally omitted someone from this list, please accept our sincere apologies. +Your contributions are truly valued, and we would be honored to include your name and acknowledgment. +Kindly reach out to us so that we can ensure proper recognition for your valuable involvement. + +Thank you to everyone who played a part in this journey. +Your impact has made a lasting difference. diff --git a/chain-api/.eslintignore b/chain-api/.eslintignore new file mode 100644 index 000000000..52224774e --- /dev/null +++ b/chain-api/.eslintignore @@ -0,0 +1,4 @@ +node_modules +lib +coverage +.eslintrc.js \ No newline at end of file diff --git a/chain-api/.eslintrc.json b/chain-api/.eslintrc.json new file mode 100644 index 000000000..4c52ca288 --- /dev/null +++ b/chain-api/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/chain-api/.prettierrc b/chain-api/.prettierrc new file mode 100644 index 000000000..5a749164f --- /dev/null +++ b/chain-api/.prettierrc @@ -0,0 +1,11 @@ +{ + "singleQuote": false, + "printWidth": 110, + "tabWidth": 2, + "trailingComma": "none", + "importOrder": ["^@oclif/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrderParserPlugins": ["typescript", "decorators-legacy"] +} diff --git a/chain-api/README.md b/chain-api/README.md new file mode 100644 index 000000000..ac15e962d --- /dev/null +++ b/chain-api/README.md @@ -0,0 +1,3 @@ +# chain-api + +This module contains DTO (Data Transfer Object), Type, and Utility function definitions that are useful for both chaincode and client implementations. diff --git a/chain-api/jest.config.ts b/chain-api/jest.config.ts new file mode 100644 index 000000000..071c9605f --- /dev/null +++ b/chain-api/jest.config.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/* eslint-disable */ +export default { + displayName: 'chain-api', + preset: '../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../coverage/chain-api' +}; diff --git a/chain-api/package.json b/chain-api/package.json new file mode 100644 index 000000000..b946f4e70 --- /dev/null +++ b/chain-api/package.json @@ -0,0 +1,57 @@ +{ + "name": "@gala-chain/api", + "version": "1.0.0", + "description": "Common types, DTOs (Data Transfer Objects), APIs, signatures, and utils for GalaChain.", + "license": "Apache-2.0", + "dependencies": { + "bignumber.js": "^9.0.2", + "bn.js": "^4.12.0", + "class-transformer": "0.5.1", + "class-validator": "^0.14.0", + "class-validator-jsonschema": "^5.0.0", + "elliptic": "^6.5.4", + "js-sha3": "^0.8.0", + "json-stringify-deterministic": "^1.0.7", + "openapi3-ts": "^3.2.0", + "reflect-metadata": "^0.1.13", + "tslib": "^2.6.2" + }, + "type": "commonjs", + "main": "./lib/src/index.js", + "typings": "./lib/src/index.d.ts", + "files": [ + "lib" + ], + "scripts": { + "clean": "tsc -b --clean", + "build": "tsc -b", + "build:watch": "tsc -b -w", + "madge": "madge --circular --warning lib", + "lint": "nx run lint", + "fix": "nx run lint --fix", + "prepublishOnly": "npm i && npm run clean && npm run build && npm run lint && npm run madge", + "format": "prettier --config ../.prettierrc 'src/**/*.ts' --write", + "test": "jest" + }, + "devDependencies": {}, + "nyc": { + "extension": [ + ".ts", + ".tsx" + ], + "exclude": [ + "coverage/**", + "dist/**" + ], + "reporter": [ + "text-summary", + "html" + ], + "all": true, + "check-coverage": true, + "statements": 100, + "branches": 100, + "functions": 100, + "lines": 100 + } +} diff --git a/chain-api/project.json b/chain-api/project.json new file mode 100644 index 000000000..2a2a88bbe --- /dev/null +++ b/chain-api/project.json @@ -0,0 +1,44 @@ +{ + "name": "chain-api", + "$schema": "../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "chain-api/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/chain-api", + "main": "chain-api/src/index.ts", + "tsConfig": "chain-api/tsconfig.lib.json", + "assets": ["chain-api/*.md"] + } + }, + "publish": { + "command": "node tools/scripts/publish.mjs chain-api {args.ver} {args.tag}", + "dependsOn": ["build"] + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["chain-api/**/*.ts", "chain-api/package.json"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "chain-api/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/chain-api/src/index.ts b/chain-api/src/index.ts new file mode 100644 index 000000000..cb4723cfc --- /dev/null +++ b/chain-api/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from "./types"; +export * from "./utils"; +export * from "./validators"; diff --git a/chain-api/src/types/AuthorizedOnBehalf.ts b/chain-api/src/types/AuthorizedOnBehalf.ts new file mode 100644 index 000000000..d00227145 --- /dev/null +++ b/chain-api/src/types/AuthorizedOnBehalf.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export interface AuthorizedOnBehalf { + callingOnBehalf: string; + callingUser: string; +} diff --git a/chain-api/src/types/BurnTokenQuantity.ts b/chain-api/src/types/BurnTokenQuantity.ts new file mode 100644 index 000000000..cc3a8d996 --- /dev/null +++ b/chain-api/src/types/BurnTokenQuantity.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { IsNotEmpty, ValidateNested } from "class-validator"; + +import { BigNumberProperty } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; +import { TokenInstanceKey } from "./TokenInstance"; + +export class BurnTokenQuantity { + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + tokenInstanceKey: TokenInstanceKey; + + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; +} diff --git a/chain-api/src/types/ChainObject.spec.ts b/chain-api/src/types/ChainObject.spec.ts new file mode 100644 index 000000000..9ea5b68c7 --- /dev/null +++ b/chain-api/src/types/ChainObject.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Transform } from "class-transformer"; + +import { BigNumberProperty, ChainKey } from "../utils"; +import { ChainObject } from "./ChainObject"; + +class TestClass extends ChainObject { + static INDEX_KEY = "test"; + + @ChainKey({ position: 0 }) + @BigNumberProperty() + bigNum: BigNumber; + + @ChainKey({ position: 1 }) + @Transform(({ value }) => `category.${value}`) + category: string; + + constructor(bigNum: BigNumber, category: string) { + super(); + this.bigNum = bigNum; + this.category = category; + } +} + +it("should use custom serializers while constructing composite key", () => { + // Given + const bigNumStr = "730750818665451215712927172538123444058715062271"; // MAX_SAFE_INTEGER^3 + const bigNum = new BigNumber(bigNumStr); + expect(bigNum.toString()).toEqual(expect.stringContaining("e+")); // by default converted to exp notation + + const dto = new TestClass(bigNum, "legendary"); + + const expectedKey = ["", TestClass.INDEX_KEY, bigNumStr, `category.legendary`, ""].join("\u0000"); + + // When + const key = dto.getCompositeKey(); + + // Then + expect(key).toEqual(expectedKey); +}); diff --git a/chain-api/src/types/ChainObject.ts b/chain-api/src/types/ChainObject.ts new file mode 100644 index 000000000..69573ead1 --- /dev/null +++ b/chain-api/src/types/ChainObject.ts @@ -0,0 +1,122 @@ +/* + * 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 { instanceToPlain } from "class-transformer"; +import { ValidationError, validate } from "class-validator"; +import "reflect-metadata"; + +import { + ChainKeyMetadata, + ValidationFailedError, + deserialize, + getValidationErrorInfo, + serialize +} from "../utils"; +import { ClassConstructor, Inferred } from "./dtos"; + +export class ObjectValidationFailedError extends ValidationFailedError { + constructor({ message, details }: { message: string; details?: string[] }) { + super(message, details); + } +} + +export class InvalidCompositeKeyError extends ValidationFailedError { + constructor(message: string) { + super(message); + } +} + +export abstract class ChainObject { + public static MIN_UNICODE_RUNE_VALUE = "\u0000"; + + public static COMPOSITEKEY_NS = "\x00"; + + // Example Composite is Org$User|TokenKey1$TokenKey2$TokenKey3|SomeOtherKey + public static ID_SPLIT_CHAR = "$"; + + public static ID_SUB_SPLIT_CHAR = "|"; + + public serialize(): string { + return serialize(this); + } + + public validate(): Promise { + return validate(this); + } + + async validateOrReject(): Promise { + const validationErrors = await this.validate(); + + if (validationErrors.length) { + throw new ObjectValidationFailedError(getValidationErrorInfo(validationErrors)); + } + } + + public toPlainObject(): Record { + return instanceToPlain(this); + } + + public static deserialize( + constructor: ClassConstructor>, + object: string | Record | Record[] + ): T { + return deserialize(constructor, object); + } + + public getCompositeKey(): string { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore this is a way to access static property of current class + const classIndexKey = this.__proto__.constructor.INDEX_KEY; + + if (classIndexKey === undefined) { + throw new InvalidCompositeKeyError( + `getCompositeKey failed because of no INDEX_KEY on ${serialize(this)}` + ); + } + + const target = Object.getPrototypeOf(this); + const fields: ChainKeyMetadata[] = Reflect.getOwnMetadata("galachain:chainkey", target) || []; + + const plain = instanceToPlain(this); + const keyParts = fields + .sort((a, b) => a.position - b.position) + .map((field) => plain[field.key.toString()]); + + return ChainObject.getCompositeKeyFromParts(classIndexKey, keyParts); + } + + public static getCompositeKeyFromParts(indexKey: string, parts: unknown[]): string { + let compositeKey = ChainObject.COMPOSITEKEY_NS + indexKey + ChainObject.MIN_UNICODE_RUNE_VALUE; + + for (const part of parts) { + if ( + part === null || + part === undefined || + typeof part === "object" || + !(typeof part["toString"] === "function") + ) { + throw new InvalidCompositeKeyError( + `Invalid part ${part} passed to getCompositeKeyFromParts: ${parts.join(", ")}` + ); + } + compositeKey = compositeKey + part + ChainObject.MIN_UNICODE_RUNE_VALUE; + } + + return compositeKey; + } + + public static getStringKeyFromParts(parts: string[]): string { + return `${parts.join(ChainObject.ID_SPLIT_CHAR)}`; + } +} diff --git a/chain-api/src/types/GrantAllowance.ts b/chain-api/src/types/GrantAllowance.ts new file mode 100644 index 000000000..ccc39dec2 --- /dev/null +++ b/chain-api/src/types/GrantAllowance.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { BigNumberProperty } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; + +export class GrantAllowanceQuantity { + @IsNotEmpty() + @IsString() + user: string; + + @BigNumberProperty() + @BigNumberIsNotNegative() + quantity: BigNumber; +} diff --git a/chain-api/src/types/LockTokenQuantity.ts b/chain-api/src/types/LockTokenQuantity.ts new file mode 100644 index 000000000..b6544650d --- /dev/null +++ b/chain-api/src/types/LockTokenQuantity.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { IsNotEmpty, IsOptional, ValidateNested } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { BigNumberProperty } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; +import { TokenInstanceKey } from "./TokenInstance"; + +export class LockTokenQuantity { + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + tokenInstanceKey: TokenInstanceKey; + + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; + + @JSONSchema({ + description: "The current owner of tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; +} diff --git a/chain-api/src/types/PublicKey.ts b/chain-api/src/types/PublicKey.ts new file mode 100644 index 000000000..eb9a1b214 --- /dev/null +++ b/chain-api/src/types/PublicKey.ts @@ -0,0 +1,30 @@ +/* + * 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 { IsNotEmpty, IsString } from "class-validator"; + +import { signatures } from "../utils"; +import { ChainObject } from "./ChainObject"; + +export class PublicKey extends ChainObject { + @IsString() + @IsNotEmpty() + publicKey: string; +} + +export const PK_INDEX_KEY = "GCPK"; + +export function normalizePublicKey(input: string): string { + return signatures.normalizePublicKey(input).toString("base64"); +} diff --git a/chain-api/src/types/RangedChainObject.spec.ts b/chain-api/src/types/RangedChainObject.spec.ts new file mode 100644 index 000000000..4c947b310 --- /dev/null +++ b/chain-api/src/types/RangedChainObject.spec.ts @@ -0,0 +1,47 @@ +/* + * 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 { Transform } from "class-transformer"; + +import { ChainKey } from "../utils"; +import { RangedChainObject } from "./RangedChainObject"; + +class TestRangedClass extends RangedChainObject { + static INDEX_KEY = "ranged-test"; + + @ChainKey({ position: 0 }) + isNft: boolean; + + @ChainKey({ position: 1 }) + @Transform(({ value }) => `cat.${value}`) + category: string; + + constructor(isNft: boolean, category: string) { + super(); + this.isNft = isNft; + this.category = category; + } +} + +it("should use custom serializers while constructing ranged key", () => { + // Given + const obj = new TestRangedClass(true, "legendary"); + const expectedKey = [TestRangedClass.INDEX_KEY, "true", "cat.legendary"].join("\u0000"); + + // When + const key = obj.getRangedKey(); + + // Then + expect(key).toEqual(expectedKey); +}); diff --git a/chain-api/src/types/RangedChainObject.ts b/chain-api/src/types/RangedChainObject.ts new file mode 100644 index 000000000..1a4142997 --- /dev/null +++ b/chain-api/src/types/RangedChainObject.ts @@ -0,0 +1,103 @@ +/* + * 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 { instanceToPlain } from "class-transformer"; +import { ValidationError, validate } from "class-validator"; +import "reflect-metadata"; + +import { + ChainKeyMetadata, + ValidationFailedError, + deserialize, + getValidationErrorInfo, + serialize +} from "../utils"; +import { ChainObject, ObjectValidationFailedError } from "./ChainObject"; +import { ClassConstructor, Inferred } from "./dtos"; + +export class InvalidRangedKeyError extends ValidationFailedError { + constructor(message: string) { + super(message); + } +} + +export abstract class RangedChainObject { + public serialize(): string { + return serialize(this); + } + + public validate(): Promise { + return validate(this); + } + + async validateOrReject(): Promise { + const validationErrors = await this.validate(); + + if (validationErrors.length) { + throw new ObjectValidationFailedError(getValidationErrorInfo(validationErrors)); + } + } + + public toPlainObject(): Record { + return instanceToPlain(this); + } + + public static deserialize( + constructor: ClassConstructor>, + object: string | Record | Record[] + ): T { + return deserialize(constructor, object); + } + + public getRangedKey(): string { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore this is a way to access static property of current class + const classIndexKey = this.__proto__.constructor.INDEX_KEY; + + if (classIndexKey === undefined) { + throw new InvalidRangedKeyError(`getCompositeKey failed because of no INDEX_KEY on ${serialize(this)}`); + } + + const target = Object.getPrototypeOf(this); + const fields: ChainKeyMetadata[] = Reflect.getOwnMetadata("galachain:chainkey", target) || []; + + const plain = instanceToPlain(this); + const keyParts = fields + .sort((a, b) => a.position - b.position) + .map((field) => plain[field.key.toString()]); + + return RangedChainObject.getRangedKeyFromParts(classIndexKey, keyParts); + } + + public static getRangedKeyFromParts(indexKey: string, parts: unknown[]): string { + for (const part of parts) { + if ( + part === null || + part === undefined || + typeof part === "object" || + !(typeof part["toString"] === "function") + ) { + throw new InvalidRangedKeyError( + `Invalid part ${part} passed to getRangedKeyFromParts: ${parts.join(", ")}` + ); + } + } + + return indexKey + ChainObject.MIN_UNICODE_RUNE_VALUE + parts.join(ChainObject.MIN_UNICODE_RUNE_VALUE); + } + + public static getStringKeyFromParts(parts: string[]): string { + return `${parts.join(ChainObject.ID_SPLIT_CHAR)}`; + } +} diff --git a/chain-api/src/types/TokenAllowance.ts b/chain-api/src/types/TokenAllowance.ts new file mode 100644 index 000000000..e798fb434 --- /dev/null +++ b/chain-api/src/types/TokenAllowance.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Exclude } from "class-transformer"; +import { IsDefined, IsInt, IsNotEmpty, IsPositive, Min } from "class-validator"; + +import { BigNumberProperty, ChainKey, EnumProperty } from "../utils"; +import { BigNumberIsInteger, BigNumberIsNotNegative, BigNumberIsPositive } from "../validators/decorators"; +import { ChainObject } from "./ChainObject"; +import { AllowanceType } from "./common"; + +export class TokenAllowance extends ChainObject { + @Exclude() + public static INDEX_KEY = "GCTA"; + + @ChainKey({ position: 0 }) + @IsNotEmpty() + public grantedTo: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 3 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 4 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + public instance: BigNumber; + + @ChainKey({ position: 6 }) + @EnumProperty(AllowanceType) + public allowanceType: AllowanceType; + + // This would make it hard to find all allowances issued out... + @ChainKey({ position: 7 }) + @IsNotEmpty() + public grantedBy: string; + + @ChainKey({ position: 8 }) + @IsPositive() + @IsInt() + public created: number; + + @BigNumberIsPositive() + @BigNumberIsInteger() + @BigNumberProperty() + public uses: BigNumber; + + @BigNumberIsNotNegative() + @BigNumberIsInteger() + @BigNumberProperty() + public usesSpent: BigNumber; + + @Min(0) + @IsInt() + public expires: number; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantitySpent: BigNumber; +} diff --git a/chain-api/src/types/TokenBalance.spec.ts b/chain-api/src/types/TokenBalance.spec.ts new file mode 100644 index 000000000..e26619134 --- /dev/null +++ b/chain-api/src/types/TokenBalance.spec.ts @@ -0,0 +1,557 @@ +import BigNumber from "bignumber.js"; + +import { TokenBalance, TokenHold } from "./TokenBalance"; + +/* + * 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. + */ + +function emptyBalance() { + return new TokenBalance({ + collection: "test-collection", + category: "test-category", + type: "test-type", + additionalKey: "test-additional-key", + owner: "user1" + }); +} + +function createHold(instance: BigNumber, expires: number) { + return new TokenHold({ + createdBy: "user1", + instanceId: instance, + quantity: new BigNumber("1"), + created: 1, + expires: expires + }); +} + +describe("fungible", () => { + describe("should have tests for fungibles", () => { + it("should add quantity", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddQuantity(new BigNumber(1)).add(); + + // Then + expect(balance.getQuantityTotal()).toEqual(new BigNumber(1)); + }); + + it("should fail to add quantity if balance contains NFT instances", () => { + // Given + const balance = emptyBalance(); + balance.ensureCanAddInstance(new BigNumber(1)).add(); + + // When + const error = () => balance.ensureCanAddQuantity(new BigNumber(1)); + + // Then + expect(error).toThrow("Attempted to perform FT-specific operation on balance containing NFT instances"); + }); + + it("should fail to add quantity if quantity is invalid", () => { + // Given + const balance = emptyBalance(); + + // When + const error = () => balance.ensureCanAddQuantity(new BigNumber(-1)); + + // Then + expect(error).toThrow("FT quantity must be positive"); + }); + + it("should subtract quantity", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddQuantity(new BigNumber(1)).add(); + balance.ensureCanSubtractQuantity(new BigNumber(1)).subtract(); + + // Then + expect(balance.getQuantityTotal()).toEqual(new BigNumber(0)); + }); + + it("should fail to subtract quantity if balance is insufficient", () => { + // Given + const balance = emptyBalance(); + + // When + const error = () => balance.ensureCanSubtractQuantity(new BigNumber(1)); + + // Then + expect(error).toThrow("Insufficient balance"); + }); + + it("should fail to subtract quantity if balance contains NFT instances", () => { + // Given + const balance = emptyBalance(); + balance.ensureCanAddInstance(new BigNumber(1)).add(); + + // When + const error = () => balance.ensureCanSubtractQuantity(new BigNumber(1)); + + // Then + expect(error).toThrow("Attempted to perform FT-specific operation on balance containing NFT instances"); + }); + + it("should fail to subtract quantity if quantity is invalid", () => { + // Given + const balance = emptyBalance(); + + // When + const error = () => balance.ensureCanSubtractQuantity(new BigNumber(-1)); + + // Then + expect(error).toThrow("FT quantity must be positive"); + }); + }); +}); + +describe("non-fungible", () => { + it("should add nft instance", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanAddInstance(new BigNumber(2)).add(); + + // Then + expect(balance.getNftInstanceCount()).toEqual(2); + }); + + it("should fail to add nft instance if instance already exists", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + + const error = () => balance.ensureCanAddInstance(new BigNumber(1)); + + // Then + expect(error).toThrow("already exists in balance"); + }); + + it("should fail to add nft instance if instance id is invalid", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + + const errorZero = () => balance.ensureCanAddInstance(new BigNumber(0)); + const errorNegative = () => balance.ensureCanAddInstance(new BigNumber(0)); + const errorDecimal = () => balance.ensureCanAddInstance(new BigNumber(0)); + + // Then + expect(errorZero).toThrow("Instance ID must be positive integer"); + expect(errorNegative).toThrow("Instance ID must be positive integer"); + expect(errorDecimal).toThrow("Instance ID must be positive integer"); + }); + + it("should remove nft instance", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanRemoveInstance(new BigNumber(1), Date.now()).remove(); + + // Then + expect(balance.getNftInstanceCount()).toEqual(0); + }); + + it("should fail to remove nft instance not in balance", () => { + // Given + const balance = emptyBalance(); + + // When + const error = () => balance.ensureCanRemoveInstance(new BigNumber(1), Date.now()); + + // Then + expect(error).toThrow("not found in balance"); + }); + + it("should fail to remove locked nft instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanLockInstance(unexpiredHold).lock(); + const error = () => balance.ensureCanRemoveInstance(new BigNumber(1), Date.now()); + + // Then + expect(error).toThrow("is locked"); + }); + + it("should fail to remove in use nft instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanUseInstance(unexpiredHold).use(); + const error = () => balance.ensureCanRemoveInstance(new BigNumber(1), Date.now()); + + // Then + expect(error).toThrow("is in use"); + }); + + it("should lock instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanLockInstance(unexpiredHold).lock(); + + // Then + expect(balance).toEqual( + expect.objectContaining({ + lockedHolds: [unexpiredHold] + }) + ); + }); + + it("should use instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanUseInstance(unexpiredHold).use(); + + // Then + expect(balance).toEqual( + expect.objectContaining({ + inUseHolds: [unexpiredHold] + }) + ); + }); + + it("should fail to lock instance for non-nft instanceId", () => { + // Given + const unexpiredHold = createHold(new BigNumber(0), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddQuantity(new BigNumber(0)).add(); + const error = () => balance.ensureCanLockInstance(unexpiredHold).lock(); + + // Then + expect(error).toThrow("Instance ID must be positive integer"); + }); + + it("should fail to use instance for non-nft instanceId", () => { + // Given + const unexpiredHold = createHold(new BigNumber(0), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddQuantity(new BigNumber(0)).add(); + const error = () => balance.ensureCanUseInstance(unexpiredHold).use(); + + // Then + expect(error).toThrow("Instance ID must be positive integer"); + }); + + it("should fail to lock instance not in balance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + const error = () => balance.ensureCanLockInstance(unexpiredHold).lock(); + + // Then + expect(error).toThrow("not found in balance"); + }); + + it("should fail to use instance not in balance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + const error = () => balance.ensureCanUseInstance(unexpiredHold).use(); + + // Then + expect(error).toThrow("not found in balance"); + }); + + it("should fail to lock in use instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanUseInstance(unexpiredHold).use(); + const error = () => balance.ensureCanLockInstance(unexpiredHold).lock(); + + // Then + expect(error).toThrow("is in use"); + }); + + it("should fail to use instance already in use", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanUseInstance(unexpiredHold).use(); + const error = () => balance.ensureCanUseInstance(unexpiredHold).use(); + + // Then + expect(error).toThrow("is in use"); + }); + + it("should fail to lock already locked instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanLockInstance(unexpiredHold).lock(); + const error = () => balance.ensureCanLockInstance(unexpiredHold).lock(); + + // Then + expect(error).toThrow("is locked"); + }); + + it("should fail to use locked instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanLockInstance(unexpiredHold).lock(); + const error = () => balance.ensureCanUseInstance(unexpiredHold).use(); + + // Then + expect(error).toThrow("is locked"); + }); + + it("should unlock locked instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanLockInstance(unexpiredHold).lock(); + balance.ensureCanUnlockInstance(new BigNumber(1), undefined, Date.now()).unlock(); + + // Then + expect(balance).toEqual( + expect.objectContaining({ + lockedHolds: [] + }) + ); + }); + + it("should fail to unlock already unlocked instance", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + const error = () => balance.ensureCanUnlockInstance(new BigNumber(1), undefined, Date.now()); + + // Then + expect(error).toThrow("is not locked"); + }); + + it("should release in use instance", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanUseInstance(unexpiredHold).use(); + balance.ensureCanReleaseInstance(new BigNumber(1), undefined, Date.now()).release(); + + // Then + expect(balance).toEqual( + expect.objectContaining({ + inUseHolds: [] + }) + ); + }); + + it("should fail to release instance not in use", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + const error = () => balance.ensureCanReleaseInstance(new BigNumber(1), undefined, Date.now()); + + // Then + expect(error).toThrow("is not in use"); + }); + + it("should find locked hold", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanLockInstance(unexpiredHold).lock(); + + const foundHold = balance.findLockedHold(new BigNumber(1), undefined, Date.now()); + + // Then + expect(foundHold).toEqual(unexpiredHold); + }); + + it("should find in use hold", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanUseInstance(unexpiredHold).use(); + + const foundHold = balance.findInUseHold(new BigNumber(1), undefined, Date.now()); + + // Then + expect(foundHold).toEqual(unexpiredHold); + }); + + it("should detect nft instance ids contained in balance", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + + const containsNft = balance.containsAnyNftInstanceId(); + + // Then + expect(containsNft).toEqual(true); + }); + + it("should be spendable if instance is in balance and free of holds", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + + const isSpendable = balance.isInstanceSpendable(new BigNumber(1), Date.now()); + + // Then + expect(isSpendable).toEqual(true); + }); + + it("should not be spendable if instance is not in balance", () => { + // Given + const balance = emptyBalance(); + + // When + const notInBalance = balance.isInstanceSpendable(new BigNumber(1), Date.now()); + + // Then + expect(notInBalance).toEqual(false); + }); + + it("should not be spendable if instance is locked", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanLockInstance(unexpiredHold).lock(); + + const notInBalance = balance.isInstanceSpendable(new BigNumber(1), Date.now()); + + // Then + expect(notInBalance).toEqual(false); + }); + + it("should not be spendable if instance is in use", () => { + // Given + const unexpiredHold = createHold(new BigNumber(1), 0); + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanUseInstance(unexpiredHold).use(); + + const notInBalance = balance.isInstanceSpendable(new BigNumber(1), Date.now()); + + // Then + expect(notInBalance).toEqual(false); + }); + + it("should get correct instanceIds array from balance", () => { + // Given + const balance = emptyBalance(); + + // When + balance.ensureCanAddInstance(new BigNumber(1)).add(); + balance.ensureCanAddInstance(new BigNumber(2)).add(); + + const instanceIds = balance.getNftInstanceIds(); + + // Then + expect(instanceIds).toEqual([new BigNumber(1), new BigNumber(2)]); + }); + + it("should clear holds", () => { + // Given + const hold6 = createHold(new BigNumber(6), 20); + const hold7 = createHold(new BigNumber(7), 99); + + const balance = emptyBalance(); + balance.ensureCanAddInstance(new BigNumber(6)).add(); + balance.ensureCanAddInstance(new BigNumber(7)).add(); + balance.ensureCanLockInstance(hold6).lock(); + balance.ensureCanUseInstance(hold7).use(); + + expect(balance).toEqual( + expect.objectContaining({ + lockedHolds: [hold6], + inUseHolds: [hold7] + }) + ); + + // Then + balance.clearHolds(new BigNumber(1), 100); + + expect(balance).toEqual( + expect.objectContaining({ + lockedHolds: [], + inUseHolds: [] + }) + ); + }); +}); diff --git a/chain-api/src/types/TokenBalance.ts b/chain-api/src/types/TokenBalance.ts new file mode 100644 index 000000000..886a0ff4d --- /dev/null +++ b/chain-api/src/types/TokenBalance.ts @@ -0,0 +1,525 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Exclude, Type } from "class-transformer"; +import { + IsDefined, + IsInt, + IsNotEmpty, + IsOptional, + IsPositive, + IsString, + Min, + ValidateNested, + validate +} from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { + BigNumberArrayProperty, + BigNumberProperty, + ChainKey, + ValidationFailedError, + getValidationErrorInfo +} from "../utils"; +import { BigNumberIsNotNegative, BigNumberIsPositive } from "../validators"; +import { ChainObject, ObjectValidationFailedError } from "./ChainObject"; +import { TokenClassKey, TokenClassKeyProperties } from "./TokenClass"; +import { TokenInstance, TokenInstanceKey } from "./TokenInstance"; + +export class TokenNotInBalanceError extends ValidationFailedError { + constructor(owner: string, tokenClass: TokenClassKeyProperties, instanceId: BigNumber) { + const tokenInstanceKey = TokenInstanceKey.nftKey(tokenClass, instanceId).toStringKey(); + super(`Token instance ${tokenInstanceKey} not found in balance`, { owner, tokenInstanceKey }); + } +} + +export class TokenLockedError extends ValidationFailedError { + constructor( + owner: string, + tokenClass: TokenClassKeyProperties, + instanceId: BigNumber, + name: string | undefined + ) { + const tokenInstanceKey = TokenInstanceKey.nftKey(tokenClass, instanceId).toStringKey(); + const lockNameInfo = name === undefined ? "" : `, lock name: ${name}`; + const message = `Token instance ${tokenInstanceKey} is locked${lockNameInfo}.`; + super(message, { owner, tokenInstanceKey, name }); + } +} + +export class TokenNotLockedError extends ValidationFailedError { + constructor(owner: string, tokenClass: TokenClassKeyProperties, instanceId: BigNumber) { + const tokenInstanceKey = TokenInstanceKey.nftKey(tokenClass, instanceId).toStringKey(); + super(`Token instance ${tokenInstanceKey} is not locked`, { owner, tokenInstanceKey }); + } +} + +export class TokenInUseError extends ValidationFailedError { + constructor(owner: string, tokenClass: TokenClassKeyProperties, instanceId: BigNumber) { + const tokenInstanceKey = TokenInstanceKey.nftKey(tokenClass, instanceId).toStringKey(); + super(`Token instance ${tokenInstanceKey} is in use`, { owner, tokenInstanceKey }); + } +} + +export class TokenNotInUseError extends ValidationFailedError { + constructor(owner: string, tokenClass: TokenClassKeyProperties, instanceId: BigNumber) { + const tokenInstanceKey = TokenInstanceKey.nftKey(tokenClass, instanceId).toStringKey(); + super(`Token instance ${tokenInstanceKey} is not in use`, { owner, tokenInstanceKey }); + } +} + +export class TokenBalance extends ChainObject { + @Exclude() + public static readonly INDEX_KEY = "GCTB"; + + @ChainKey({ position: 0 }) + @IsNotEmpty() + public readonly owner: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public readonly collection: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public readonly category: string; + + @ChainKey({ position: 3 }) + @IsNotEmpty() + public readonly type: string; + + @ChainKey({ position: 4 }) + @IsDefined() + public readonly additionalKey: string; + + constructor(params?: { + owner: string; + collection: string; + category: string; + type: string; + additionalKey: string; + }) { + super(); + if (params) { + this.owner = params.owner; + this.collection = params.collection; + this.category = params.category; + this.type = params.type; + this.additionalKey = params.additionalKey; + this.quantity = new BigNumber(0); + this.instanceIds = []; + this.lockedHolds = []; + this.inUseHolds = []; + } + } + + /** + * Token instance IDs for NFTs. It is also used to determine if the balance is + * for fungible or non-fungible tokens. If the array is undefined, then the + * balance is for fungible tokens. + */ + @IsOptional() + @BigNumberArrayProperty() + private instanceIds?: Array; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => TokenHold) + private lockedHolds?: Array; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => TokenHold) + private inUseHolds?: Array; + + @BigNumberIsNotNegative() + @BigNumberProperty() + private quantity: BigNumber; + + // + // NFT + // + + public getNftInstanceCount(): number { + return this.getNftInstanceIds().length; + } + + public ensureCanAddInstance(instanceId: BigNumber): { add(): void } { + this.ensureInstanceIsNft(instanceId); + + if (this.containsInstance(instanceId)) { + throw new ValidationFailedError(`Token instance ${instanceId} already exists in balance`, { + balanceKey: TokenClassKey.toStringKey(this), + instanceId: instanceId.toString() + }); + } + + const add = () => { + if (this.instanceIds === undefined) { + this.instanceIds = []; + } + + // add instance ID to array + this.instanceIds.push(instanceId); + this.instanceIds.sort((i) => i.comparedTo(i)); + + // update quantity + this.quantity = new BigNumber(this.instanceIds.length); + }; + + return { add }; + } + + public ensureCanRemoveInstance(instanceId: BigNumber, currentTime: number): { remove(): void } { + this.ensureInstanceIsNft(instanceId); + this.ensureInstanceIsInBalance(instanceId); + this.ensureInstanceIsNotLocked(instanceId, currentTime); + this.ensureInstanceIsNotUsed(instanceId, currentTime); + + const remove = () => { + // remove instance ID from array + this.instanceIds = (this.instanceIds ?? []).filter((id) => !id.eq(instanceId)); + + // update quantity + this.quantity = new BigNumber(this.instanceIds.length); + }; + + return { remove }; + } + + public ensureCanLockInstance(hold: TokenHold): { lock(): void } { + this.ensureInstanceIsNft(hold.instanceId); + this.ensureInstanceIsInBalance(hold.instanceId); + this.ensureInstanceIsNotLockedWithTheSameName(hold.instanceId, hold.name, hold.created); + this.ensureInstanceIsNotUsed(hold.instanceId, hold.created); + + const lock = () => { + this.lockedHolds = [...this.getUnexpiredLockedHolds(hold.created), hold]; + }; + + return { lock }; + } + + public ensureCanUnlockInstance( + instanceId: BigNumber, + name: string | undefined, + currentTime: number + ): { unlock(): void } { + const unexpiredLockedHolds = this.getUnexpiredLockedHolds(currentTime); + const updated = unexpiredLockedHolds.filter((h) => !h.matches(instanceId, name)); + + if (unexpiredLockedHolds.length === updated.length) { + throw new TokenNotLockedError(this.owner, this, instanceId); + } + + const unlock = () => { + this.lockedHolds = updated; + }; + + return { unlock }; + } + + public ensureCanUseInstance(hold: TokenHold): { use(): void } { + this.ensureInstanceIsNft(hold.instanceId); + this.ensureInstanceIsInBalance(hold.instanceId); + this.ensureInstanceIsNotLocked(hold.instanceId, hold.created); + this.ensureInstanceIsNotUsed(hold.instanceId, hold.created); + + const use = () => { + this.inUseHolds = [...this.getUnexpiredInUseHolds(hold.created), hold]; + }; + + return { use }; + } + + public ensureCanReleaseInstance( + instanceId: BigNumber, + name: string | undefined, + currentTime: number + ): { release(): void } { + const unexpiredInUseHolds = this.getUnexpiredInUseHolds(currentTime); + const updated = unexpiredInUseHolds.filter((h) => !h.matches(instanceId, name)); + + if (unexpiredInUseHolds.length === updated.length) { + throw new TokenNotInUseError(this.owner, this, instanceId); + } + + const release = () => { + this.inUseHolds = updated; + }; + + return { release }; + } + + public clearHolds(instanceId: BigNumber, currentTime: number): void { + this.ensureInstanceIsNft(instanceId); + + this.lockedHolds = this.getUnexpiredLockedHolds(currentTime).filter( + (h) => !h.instanceId.isEqualTo(instanceId) + ); + this.inUseHolds = this.getUnexpiredInUseHolds(currentTime).filter( + (h) => !h.instanceId.isEqualTo(instanceId) + ); + } + + public findLockedHold( + instanceId: BigNumber, + name: string | undefined, + currentTime: number + ): TokenHold | undefined { + this.ensureInstanceIsNft(instanceId); + return this.getUnexpiredLockedHolds(currentTime).find((h) => h.matches(instanceId, name)); + } + + public findInUseHold( + instanceId: BigNumber, + name: string | undefined, + currentTime: number + ): TokenHold | undefined { + this.ensureInstanceIsNft(instanceId); + return this.getUnexpiredInUseHolds(currentTime).find((h) => h.matches(instanceId, name)); + } + + public containsAnyNftInstanceId(): boolean { + return this.getNftInstanceIds().length > 0; + } + + public isInstanceSpendable(instanceId: BigNumber, currentTime: number): boolean { + return ( + this.containsInstance(instanceId) && + !this.isInstanceLocked(instanceId, currentTime) && + !this.isInstanceInUse(instanceId, currentTime) + ); + } + + public getNftInstanceIds(): BigNumber[] { + return this.instanceIds?.filter((id) => !TokenInstance.isFungible(id)) ?? []; + } + + public cleanupExpiredHolds(currentTime: number): TokenBalance { + this.lockedHolds = this.getUnexpiredLockedHolds(currentTime); + this.inUseHolds = this.getUnexpiredInUseHolds(currentTime); + return this; + } + + private containsInstance(instanceId: BigNumber): boolean { + return this.instanceIds?.some((id) => id.isEqualTo(instanceId)) ?? false; + } + + private isInstanceLocked(instanceId: BigNumber, currentTime: number): boolean { + return this.getUnexpiredLockedHolds(currentTime).some((h) => h.instanceId.isEqualTo(instanceId)); + } + + private isInstanceInUse(instanceId: BigNumber, currentTime: number): boolean { + return this.getUnexpiredInUseHolds(currentTime).some((h) => h.instanceId.isEqualTo(instanceId)); + } + + private ensureInstanceIsNft(instanceId: BigNumber): void { + if (instanceId.isNegative() || instanceId.isZero() || !instanceId.isInteger()) { + const message = `Instance ID must be positive integer, but got ${instanceId.toFixed()}`; + throw new ValidationFailedError(message, { instanceId: instanceId.toFixed() }); + } + } + + private ensureInstanceIsInBalance(instanceId: BigNumber): void { + if (!this.containsInstance(instanceId)) { + throw new TokenNotInBalanceError(this.owner, this, instanceId); + } + } + + private ensureInstanceIsNotLockedWithTheSameName( + instanceId: BigNumber, + name: string | undefined, + currentTime: number + ): void { + const hold = this.findLockedHold(instanceId, name, currentTime); + if (hold !== undefined) { + throw new TokenLockedError(this.owner, this, instanceId, name); + } + } + + private ensureInstanceIsNotLocked(instanceId: BigNumber, currentTime: number): void { + const hold = this.getUnexpiredLockedHolds(currentTime).find((h) => h.instanceId.isEqualTo(instanceId)); + if (hold !== undefined) { + throw new TokenLockedError(this.owner, this, instanceId, hold?.name); + } + } + + private ensureInstanceIsNotUsed(instanceId: BigNumber, currentTime: number): void { + if (this.isInstanceInUse(instanceId, currentTime)) { + throw new TokenInUseError(this.owner, this, instanceId); + } + } + // + // Fungible API + // + + public getQuantityTotal(): BigNumber { + this.ensureContainsNoNftInstances(); + return this.quantity; + } + + public ensureCanAddQuantity(quantity: BigNumber): { add(): void } { + this.ensureContainsNoNftInstances(); + this.ensureIsValidQuantityForFungible(quantity); + + const add = () => { + this.quantity = this.quantity.plus(quantity); + }; + + return { add }; + } + + public ensureCanSubtractQuantity(quantity: BigNumber): { subtract(): void } { + this.ensureContainsNoNftInstances(); + this.ensureIsValidQuantityForFungible(quantity); + this.ensureQuantityIsSpendable(quantity); + + const subtract = () => { + this.quantity = this.quantity.minus(quantity); + }; + + return { subtract }; + } + + private ensureQuantityIsSpendable(quantity: BigNumber): void { + // locked & in use are not supported for fungibles + const spendableQuantity = this.quantity; + + if (spendableQuantity.isLessThan(quantity)) { + throw new ValidationFailedError("Insufficient balance", { + balanceKey: this.getCompositeKey(), + total: this.quantity.toFixed() + }); + } + } + + public getUnexpiredLockedHolds(currentTime: number): TokenHold[] { + return (this.lockedHolds ?? []).filter((h) => !h.isExpired(currentTime)); + } + + public getUnexpiredInUseHolds(currentTime: number): TokenHold[] { + return (this.inUseHolds ?? []).filter((h) => !h.isExpired(currentTime)); + } + + private ensureContainsNoNftInstances(): void { + if (this.containsAnyNftInstanceId()) { + const message = `Attempted to perform FT-specific operation on balance containing NFT instances`; + throw new ValidationFailedError(message, { + currentInstanceIds: this.instanceIds, + tokenClassKey: TokenClassKey.toStringKey(this) + }); + } + } + + private ensureIsValidQuantityForFungible(quantity: BigNumber): void { + if (quantity.isNegative()) { + throw new ValidationFailedError(`FT quantity must be positive`, { + balanceKey: this.getCompositeKey(), + quantity: quantity.toString() + }); + } + } +} + +export class TokenHold { + public static readonly DEFAULT_EXPIRES = 0; + + @IsNotEmpty() + public readonly createdBy: string; + + @IsNotEmpty() + @BigNumberIsPositive() + @BigNumberProperty() + public readonly instanceId: BigNumber; + + @BigNumberIsNotNegative() + @BigNumberProperty() + public readonly quantity: BigNumber; + + @IsPositive() + @IsInt() + public readonly created: number; + + @Min(0) + @IsInt() + public readonly expires: number; + + @IsString() + @IsOptional() + public readonly name?: string; + + @JSONSchema({ + 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." + }) + @IsNotEmpty() + @IsOptional() + lockAuthority?: string; + + public constructor(params?: { + createdBy: string; + instanceId: BigNumber; + quantity: BigNumber; + created: number; + expires?: number; + name?: string; + lockAuthority?: string; + }) { + if (params) { + this.createdBy = params.createdBy; + this.instanceId = params.instanceId; + this.quantity = params.quantity; + this.created = params.created; + this.expires = params.expires ?? TokenHold.DEFAULT_EXPIRES; + if (params.name) { + this.name = params.name; + } + if (params.lockAuthority) { + this.lockAuthority = params.lockAuthority; + } + } + } + + public static async createValid(params: { + createdBy: string; + instanceId: BigNumber; + quantity: BigNumber; + created: number; + expires: number | undefined; + name: string | undefined; + lockAuthority: string | undefined; + }): Promise { + const hold = new TokenHold({ ...params }); + + const errors = await validate(hold); + if (errors.length > 0) { + throw new ObjectValidationFailedError(getValidationErrorInfo(errors)); + } + + return hold; + } + + public matches(instanceId: BigNumber, name: string | undefined): boolean { + return this.instanceId.isEqualTo(instanceId) && this.name === name; + } + + public isExpired(currentTime: number): boolean { + return this.expires !== 0 && currentTime > this.expires; + } +} diff --git a/chain-api/src/types/TokenBurn.ts b/chain-api/src/types/TokenBurn.ts new file mode 100644 index 000000000..95262333f --- /dev/null +++ b/chain-api/src/types/TokenBurn.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Exclude } from "class-transformer"; +import { IsDefined, IsInt, IsNotEmpty, IsPositive } from "class-validator"; + +import { BigNumberProperty, ChainKey } from "../utils"; +import { BigNumberIsInteger, BigNumberIsNotNegative } from "../validators"; +import { ChainObject } from "./ChainObject"; + +export class TokenBurn extends ChainObject { + @Exclude() + public static INDEX_KEY = "GCTBR"; + + @ChainKey({ position: 0 }) + @IsNotEmpty() + public burnedBy: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 3 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 4 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + public instance: BigNumber; + + @ChainKey({ position: 6 }) + @IsPositive() + @IsInt() + public created: number; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; +} diff --git a/chain-api/src/types/TokenBurnCounter.ts b/chain-api/src/types/TokenBurnCounter.ts new file mode 100644 index 000000000..fc64ce60f --- /dev/null +++ b/chain-api/src/types/TokenBurnCounter.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Exclude } from "class-transformer"; +import { IsDefined, IsNotEmpty } from "class-validator"; + +import { BigNumberProperty, ChainKey } from "../utils"; +import { BigNumberIsInteger, BigNumberIsNotNegative } from "../validators"; +import { ChainObject } from "./ChainObject"; +import { RangedChainObject } from "./RangedChainObject"; + +export class TokenBurnCounter extends RangedChainObject { + public static INDEX_KEY = "GCTBRC"; + + @ChainKey({ position: 0 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 3 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 4 }) + @IsNotEmpty() + public timeKey: string; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + public burnedBy: string; + + @ChainKey({ position: 6 }) + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + public instance: BigNumber; + + @ChainKey({ position: 7 }) + @IsNotEmpty() + @BigNumberProperty() + public totalKnownBurnsCount: BigNumber; + + @IsNotEmpty() + public created: number; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; + + // id of a referenced TokenBurn + @IsNotEmpty() + public referenceId: string; + + // todo: revisit epoch as chain key if/when fabric implements it beyond hard-coded 0 + // @ChainKey({ position: 4 }) + @IsNotEmpty() + public epoch: string; + + @Exclude() + public referencedBurnId(): string { + const { collection, category, type, additionalKey, burnedBy, created } = this; + + return ChainObject.getStringKeyFromParts([ + burnedBy, + collection, + category, + type, + additionalKey, + burnedBy, + `${created}` + ]); + } +} diff --git a/chain-api/src/types/TokenClaim.ts b/chain-api/src/types/TokenClaim.ts new file mode 100644 index 000000000..398fffbe8 --- /dev/null +++ b/chain-api/src/types/TokenClaim.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Exclude } from "class-transformer"; +import { IsDefined, IsInt, IsNotEmpty, IsPositive } from "class-validator"; + +import { BigNumberProperty, ChainKey, EnumProperty } from "../utils"; +import { BigNumberIsInteger, BigNumberIsNotNegative } from "../validators"; +import { ChainObject } from "./ChainObject"; +import { AllowanceType } from "./common"; + +// A Token Claim is the other side of an allowance +// A person may have an allowance to do something, +// but it is not used and the token is not held until +// a claim is made + +// This class is a prototype for future use. +// If we need to move away from the Balance approach +// where the Balance is updated with locks/uses then +// we will need to aggregate Claims to determine the +// usable balance + +// TODO possible legacy, maybe we can use claims as an array in allowance +export class TokenClaim extends ChainObject { + @Exclude() + public static INDEX_KEY = "GCTC"; + + // This is the owner of the allowance, not the token + @ChainKey({ position: 0 }) + @IsNotEmpty() + public ownerKey: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 3 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 4 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberProperty() + public instance: BigNumber; + + @ChainKey({ position: 6 }) + @EnumProperty(AllowanceType) + public action: AllowanceType; + + // This is the person making the claim + @ChainKey({ position: 7 }) + @IsNotEmpty() + public issuerKey: string; + + @ChainKey({ position: 8 }) + @IsPositive() + @IsInt() + public allowanceCreated: number; + + @ChainKey({ position: 9 }) + @BigNumberIsNotNegative() + @BigNumberIsInteger() + @BigNumberProperty() + public claimSequence: BigNumber; + + @IsPositive() + @IsInt() + public created: number; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; +} diff --git a/chain-api/src/types/TokenClass.spec.ts b/chain-api/src/types/TokenClass.spec.ts new file mode 100644 index 000000000..420e7b4a3 --- /dev/null +++ b/chain-api/src/types/TokenClass.spec.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { plainToClass as plainToInstance } from "class-transformer"; + +import { TokenClass } from "./TokenClass"; + +const existingToken = plainToInstance(TokenClass, { + network: "GC", + decimals: 32, + maxCapacity: new BigNumber("50000000000"), + maxSupply: new BigNumber("50000000000"), + collection: "Platform", + category: "Currency", + type: "TEST123", + additionalKey: "none", + name: "TestCoin", + symbol: "TEST", + description: "string", + isNonFungible: false, + contractAddress: "test-address", + metadataAddress: "test-metadata", + rarity: "common", + totalBurned: new BigNumber("0"), + totalSupply: new BigNumber("50000000000"), + totalMintAllowance: new BigNumber("50000000000"), + image: "https://app.gala.games/_nuxt/img/gala-logo_horizontal_white.8b0409c.png", + authorities: ["client|old-admin"] +}); + +it("should update properties that are allowed to be updated", async () => { + // Given - an update with redundant properties + const update = { + network: "QQ", + decimals: 0, + maxCapacity: new BigNumber("1"), + maxSupply: new BigNumber("1"), + collection: "UpdatedPlatform", + category: "UpdatedCurrency", + type: "UPDTEST123", + additionalKey: "upd-none", + name: "UpdatedTestCoin", + symbol: "UPDTEST", + description: "updated-string", + isNonFungible: true, + contractAddress: "updated-test-address", + metadataAddress: "updated-test-metadata", + rarity: "updated-common", + totalBurned: new BigNumber("999"), + totalSupply: new BigNumber("998"), + totalMintAllowance: new BigNumber("997"), + image: "https://app.gala.games/_nuxt/img/updated-gala-logo_horizontal_white.8b0409c.png", + authorities: ["client|new-admin"] + }; + + // When + const updatedToken = existingToken.updatedWith(update); + + // Then + expect(updatedToken.toPlainObject()).toEqual({ + ...existingToken.toPlainObject(), + name: update.name, + symbol: update.symbol, + description: update.description, + rarity: update.rarity, + image: update.image, + contractAddress: update.contractAddress, + metadataAddress: update.metadataAddress, + authorities: ["client|new-admin", "client|old-admin"] // sorted + }); +}); + +it("should allow to override authorities", async () => { + // Given + const update = { + authorities: ["client|new-admin"], + overwriteAuthorities: true + }; + + // When + const updatedToken = existingToken.updatedWith(update); + + // Then + expect(updatedToken.toPlainObject()).toEqual({ + ...existingToken.toPlainObject(), + authorities: ["client|new-admin"] + }); +}); diff --git a/chain-api/src/types/TokenClass.ts b/chain-api/src/types/TokenClass.ts new file mode 100644 index 000000000..9f1ff6813 --- /dev/null +++ b/chain-api/src/types/TokenClass.ts @@ -0,0 +1,270 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Exclude, instanceToInstance } from "class-transformer"; +import { + Equals, + IsAlpha, + IsBoolean, + IsDefined, + IsNotEmpty, + IsOptional, + IsString, + Max, + MaxLength, + Min +} from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { BigNumberProperty, ChainKey } from "../utils"; +import { BigNumberIsPositive } from "../validators"; +import { ChainObject } from "./ChainObject"; +import { GC_NETWORK_ID } from "./contract"; +import { ChainCallDTO } from "./dtos"; + +export interface TokenClassKeyProperties { + collection: string; + category: string; + type: string; + additionalKey: string; +} + +@JSONSchema({ + description: "Object representing the chain identifier of token class." +}) +export class TokenClassKey extends ChainCallDTO { + @IsNotEmpty() + public collection: string; + + @IsNotEmpty() + public category: string; + + @IsNotEmpty() + public type: string; + + @IsDefined() + public additionalKey: string; + + public toString() { + return this.toStringKey(); + } + + public toStringKey(): string { + const keyList = TokenClass.buildClassKeyList(this); + return ChainObject.getStringKeyFromParts(keyList); + } + + public static toStringKey(props: TokenClassKeyProperties): string { + const keyList = TokenClass.buildClassKeyList(props); + return ChainObject.getStringKeyFromParts(keyList); + } + + public allKeysPresent(): boolean { + const keysAndValues = Object.entries(this); + if (keysAndValues.length !== 4) return false; + + const additionalKeyPresent = typeof this.additionalKey === "string"; + if (this.collection && this.category && this.type && additionalKeyPresent) return true; + + return false; + } +} + +export class TokenClass extends ChainObject { + public static INDEX_KEY = "GCTI"; + + /// /////////////////////////////////////////////////// + // READ-ONLY PROPERTIES + // CANNOT BE CHANGED AFTER CREATION + @ChainKey({ position: 0 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 3 }) + @IsDefined() + public additionalKey: string; + + @IsNotEmpty() + @Equals(GC_NETWORK_ID) + public network: string; + + /// /////////////////////////////////////////////////// + // READ-ONLY PROPERTIES + // CAN ONLY BE MODIFIED BY CHAINCODE CALLS + // + + @Min(0) + @Max(32) + public decimals: number; // This can only be expanded after creation + + @IsNotEmpty() + @BigNumberIsPositive() + @BigNumberProperty() + public maxSupply: BigNumber; + + @IsBoolean() + public isNonFungible: boolean; + + @IsNotEmpty() + @BigNumberIsPositive() + @BigNumberProperty({ allowInfinity: true }) + public maxCapacity: BigNumber; + + // IDs of authorities who can manage this token + @IsString({ each: true }) + public authorities: Array; + + /// /////////////////////////////////////////////////// + // Permissioned Properties (Authorities can directly modify) + @IsNotEmpty() + public name: string; + + @IsNotEmpty() + @IsAlpha() + public symbol: string; + + @IsNotEmpty() + @MaxLength(1000) + public description: string; + + // This is for tracking external token data. We don't use it internally (I think?) + @IsOptional() + @MaxLength(500) + public contractAddress?: string; + + // Contract address where the information about the NFT will live + @IsOptional() + @MaxLength(500) + public metadataAddress?: string; + + // a URI to the image + @IsNotEmpty() + @MaxLength(500) + public image: string; + + // Rarity of the NFT + @IsOptional() + @IsAlpha() + public rarity?: string; + + @BigNumberIsPositive() + @BigNumberProperty() + public totalBurned: BigNumber; + + @BigNumberProperty() + public totalMintAllowance: BigNumber; + + @IsOptional() + @BigNumberProperty() + public knownMintAllowanceSupply?: BigNumber; + + /** + * Total supply of tokens minted for class. + * + * @deprecated 2023-05-30, replaced with knownMintSupply for high-throughput implementation. + */ + @BigNumberProperty() + public totalSupply: BigNumber; + + @IsOptional() + @BigNumberProperty() + public knownMintSupply?: BigNumber; + + @Exclude() + public getKey(): Promise { + return TokenClass.buildClassKeyObject(this); + } + + public static buildClassKeyList( + tokenClassKey: TokenClassKeyProperties + ): [collection: string, category: string, type: string, additionalKey: string] { + const { collection, category, type, additionalKey } = tokenClassKey; + return [collection, category, type, additionalKey]; + } + + @Exclude() + public static buildTokenClassCompositeKey(tokenClassKey: TokenClassKeyProperties): string { + const partialClassObj = new TokenClass(); + partialClassObj.collection = tokenClassKey.collection; + partialClassObj.category = tokenClassKey.category; + partialClassObj.type = tokenClassKey.type; + partialClassObj.additionalKey = tokenClassKey.additionalKey; + return partialClassObj.getCompositeKey(); + } + + @Exclude() + public static async buildClassKeyObject(token: TokenClassKeyProperties): Promise { + const tokenClassKey = new TokenClassKey(); + + tokenClassKey.collection = token?.collection ?? null; + tokenClassKey.category = token?.category ?? null; + tokenClassKey.type = token?.type ?? null; + tokenClassKey.additionalKey = token?.additionalKey ?? null; + + const instanceValidationErrors = await tokenClassKey.validate(); + + if (instanceValidationErrors.length !== 0) { + throw new Error(instanceValidationErrors.join(". ")); + } + + return tokenClassKey; + } + + /** + * Returns new token class object updated with properties that are allowed to be updated + */ + public updatedWith(toUpdate: ToUpdate): TokenClass { + return createUpdated(this, toUpdate); + } +} + +interface ToUpdate { + name?: string; + symbol?: string; + description?: string; + contractAddress?: string; + metadataAddress?: string; + rarity?: string; + image?: string; + authorities?: string[]; + overwriteAuthorities?: boolean; +} + +function createUpdated(existingToken: TokenClass, toUpdate: ToUpdate): TokenClass { + const newToken = instanceToInstance(existingToken); + newToken.name = toUpdate.name ?? existingToken.name; + newToken.symbol = toUpdate.symbol ?? existingToken.symbol; + newToken.description = toUpdate.description ?? existingToken.description; + newToken.contractAddress = toUpdate.contractAddress ?? existingToken.contractAddress; + newToken.metadataAddress = toUpdate.metadataAddress ?? existingToken.metadataAddress; + newToken.rarity = toUpdate.rarity ?? existingToken.rarity; + newToken.image = toUpdate.image ?? existingToken.image; + + if (Array.isArray(toUpdate.authorities) && toUpdate.authorities.length > 0) { + newToken.authorities = toUpdate.overwriteAuthorities + ? toUpdate.authorities + : Array.from(new Set(newToken.authorities.concat(toUpdate.authorities))).sort(); + } + + return newToken; +} diff --git a/chain-api/src/types/TokenInstance.spec.ts b/chain-api/src/types/TokenInstance.spec.ts new file mode 100644 index 000000000..a96c156e6 --- /dev/null +++ b/chain-api/src/types/TokenInstance.spec.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; + +import { TokenInstance } from "./TokenInstance"; + +it("should get proper value for long instance key", async () => { + // Given + const bigInstanceNumber = new BigNumber(Number.MAX_SAFE_INTEGER).pow(2); + + const instance = new TokenInstance(); + instance.collection = "Test"; + instance.category = "Very"; + instance.type = "Large"; + instance.additionalKey = "Instance"; + instance.instance = bigInstanceNumber; + + const expectedKey1Parts = [ + instance.collection, + "\u0000", + instance.category, + "\u0000", + instance.type, + "\u0000", + instance.additionalKey, + "\u0000", + instance.instance.toFixed() + ]; + + const expectedKey2Parts = ["\u0000", TokenInstance.INDEX_KEY, "\u0000", ...expectedKey1Parts, "\u0000"]; + + // When + const key1 = instance.GetCompositeKeyString(); + const key2 = instance.getCompositeKey(); + + // Then + expect([key1, key2]).toEqual([expectedKey1Parts.join(""), expectedKey2Parts.join("")]); +}); diff --git a/chain-api/src/types/TokenInstance.ts b/chain-api/src/types/TokenInstance.ts new file mode 100644 index 000000000..0380b6361 --- /dev/null +++ b/chain-api/src/types/TokenInstance.ts @@ -0,0 +1,343 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { Exclude, Type, classToPlain as instanceToPlain } from "class-transformer"; +import { + IsBoolean, + IsDefined, + IsNotEmpty, + IsObject, + IsOptional, + ValidateIf, + ValidateNested +} from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { ChainKey } from "../utils/chain-decorators"; +import { BigNumberProperty } from "../utils/transform-decorators"; +import { BigNumberIsInteger, BigNumberIsNotNegative } from "../validators/decorators"; +import { ChainObject } from "./ChainObject"; +import { TokenClass, TokenClassKey, TokenClassKeyProperties } from "./TokenClass"; +import { ChainCallDTO } from "./dtos"; + +export interface TokenInstanceKeyProperties { + collection: string; + category: string; + type: string; + additionalKey: string; + instance: BigNumber; +} + +@JSONSchema({ + description: "Object representing the chain identifier of token instance." +}) +export class TokenInstanceKey extends ChainCallDTO { + @IsNotEmpty() + public collection: string; + + @IsNotEmpty() + public category: string; + + @IsNotEmpty() + public type: string; + + @IsDefined() + public additionalKey: string; + + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + public instance: BigNumber; + + public static nftKey( + c: TokenClassKey | TokenClass | TokenClassKeyProperties, + instance: BigNumber | string | number + ): TokenInstanceKey { + const instanceKey = new TokenInstanceKey(); + instanceKey.collection = c.collection; + instanceKey.category = c.category; + instanceKey.type = c.type; + instanceKey.additionalKey = c.additionalKey; + instanceKey.instance = new BigNumber(instance); + + return instanceKey; + } + + public static fungibleKey(c: TokenClassKey | TokenClass): TokenInstanceKey { + return TokenInstanceKey.nftKey(c, TokenInstance.FUNGIBLE_TOKEN_INSTANCE); + } + + public getTokenClassKey(): TokenClassKey { + const returnKey = new TokenClassKey(); + returnKey.category = this.category; + returnKey.collection = this.collection; + returnKey.type = this.type; + returnKey.additionalKey = this.additionalKey; + + return returnKey; + } + + public toQueryKey(): TokenInstanceQueryKey { + const queryKey = new TokenInstanceQueryKey(); + queryKey.collection = this.collection; + queryKey.category = this.category; + queryKey.type = this.type; + queryKey.additionalKey = this.additionalKey; + queryKey.instance = this.instance; + + return queryKey; + } + + public toString() { + return this.toStringKey(); + } + + public toStringKey(): string { + const keyList = TokenInstance.buildInstanceKeyList(this); + return ChainObject.getStringKeyFromParts(keyList); + } + + public isFungible(): boolean { + return TokenInstance.isFungible(this.instance); + } +} + +export class TokenInstanceQuantity extends ChainCallDTO { + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + public tokenInstance: TokenInstanceKey; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; + + @IsOptional() + @JSONSchema({ + description: "The TokenClass metadata corresponding to the TokenBalance on this DTO." + }) + @Type(() => TokenClass) + @IsObject() + tokenMetadata?: TokenClass; + + public getTokenClassKey(this: TokenInstanceQuantity): TokenClassKey { + return this.tokenInstance.getTokenClassKey(); + } + + public toString(this: TokenInstanceQuantity) { + return this.tokenInstance.toStringKey(); + } + + public toStringKey(this: TokenInstanceQuantity): string { + return this.tokenInstance.toStringKey(); + } +} + +@JSONSchema({ + description: + "A full or partial key of a TokenInstance, for querying or actioning one or more instances of a token." +}) +export class TokenInstanceQueryKey extends ChainCallDTO { + @IsNotEmpty() + public collection: string; + + @IsOptional() + public category?: string; + + @IsOptional() + public type?: string; + + @IsOptional() + public additionalKey?: string; + + @IsOptional() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + public instance?: BigNumber; + + public isCompleteKey(): boolean { + // for feature parity and transformation to TokenInstanceKey for compatibility with existing chain calls + return !!( + this.collection && + this.category && + this.type && + typeof this.additionalKey === "string" && + BigNumber.isBigNumber(this.instance) + ); + } + + public toCompleteKey(): TokenInstanceKey { + // a TokenInstanceKey can always convert to a TokenQueryKey, + // but a query key must have all required properties to convert to a TokenInstanceKey + if ( + !( + this.collection && + this.category && + this.type && + typeof this.additionalKey === "string" && + BigNumber.isBigNumber(this.instance) + ) + ) { + throw new Error( + `Attempted to convert partial key to complete instance key with missing properties: ${this.toQueryParams().join( + ", " + )}` + ); + } + + const instanceKey = new TokenInstanceKey(); + + instanceKey.collection = this.collection; + instanceKey.category = this.category; + instanceKey.type = this.type; + instanceKey.additionalKey = this.additionalKey; + instanceKey.instance = this.instance; + + return instanceKey; + } + + publicKeyProperties() { + // key properties, in order, to support partial key construction. + // fabric permits partial keys, in order of specificity, with no gaps. + // e.g. if "type" is undefined, "additionalKey" must not be specified. + return ["collection", "category", "type", "additionalKey"]; + } + + public toQueryParams() { + const queryParams: string[] = []; + + const publicKeyProperties = this.publicKeyProperties(); + for (const property of publicKeyProperties) { + if (typeof this[property] !== "string") { + break; + } + queryParams.push(this[property]); + } + return queryParams; + } +} + +export class TokenInstance extends ChainObject { + @ChainKey({ position: 0 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 3 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 4 }) + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + public instance: BigNumber; + + @IsBoolean() + public isNonFungible: boolean; + + @ValidateIf((i) => i.isNonFungible === true) + @IsNotEmpty() + public owner?: string; + + public static INDEX_KEY = "GCTI2"; + + public static FUNGIBLE_TOKEN_INSTANCE = new BigNumber(0); + + // This returns the unique identifying string used in the composite key for querying HLF + @Exclude() + public GetCompositeKeyString(): string { + return TokenInstance.CreateCompositeKey(this); + } + + @Exclude() + public static GetFungibleInstanceFromClass(token: TokenClassKeyProperties): string { + const { MIN_UNICODE_RUNE_VALUE } = ChainObject; + const { collection, category, type, additionalKey } = token; + + return [collection, category, type, additionalKey, this.FUNGIBLE_TOKEN_INSTANCE].join( + MIN_UNICODE_RUNE_VALUE + ); + } + + // This returns the unique identifying string used in the composite key for querying HLF + @Exclude() + public static CreateCompositeKey(token: TokenInstanceKeyProperties): string { + const { MIN_UNICODE_RUNE_VALUE } = ChainObject; + const { collection, category, type, additionalKey, instance } = instanceToPlain(token); + + return [collection, category, type, additionalKey, instance].join(MIN_UNICODE_RUNE_VALUE); + } + + // 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 | + @Exclude() + public static GetCompositeKeyFromString(tokenCid: string): string { + const idParts = tokenCid.split(ChainObject.ID_SPLIT_CHAR); + + // We expect two parts, and for the second one to be a number + if (idParts.length !== 2 || Number.isNaN(Number.parseInt(idParts[1]))) { + throw new Error(`Invalid string passed to TokenInstance.GetCompositeKeyFromString : ${tokenCid}`); + } else { + return ChainObject.getCompositeKeyFromParts(TokenInstance.INDEX_KEY, idParts); + } + } + + @Exclude() + public static buildInstanceKeyList( + token: TokenInstanceKeyProperties + ): [collection: string, category: string, type: string, additionalKey: string, instance: string] { + const { collection, category, type, additionalKey, instance } = token; + return [collection, category, type, additionalKey, instance.toString()]; + } + + @Exclude() + public static async buildInstanceKeyObject(token: TokenInstanceKeyProperties): Promise { + const tokenInstanceKey = new TokenInstanceKey(); + + tokenInstanceKey.collection = token?.collection ?? null; + tokenInstanceKey.category = token?.category ?? null; + tokenInstanceKey.type = token?.type ?? null; + tokenInstanceKey.additionalKey = token?.additionalKey ?? null; + tokenInstanceKey.instance = token?.instance ?? null; + + const instanceValidationErrors = await tokenInstanceKey.validate(); + + if (instanceValidationErrors.length !== 0) { + throw new Error(instanceValidationErrors.join(". ")); + } + + return tokenInstanceKey; + } + + public static isFungible(instanceId: BigNumber): boolean { + return TokenInstance.FUNGIBLE_TOKEN_INSTANCE.isEqualTo(instanceId); + } + + public static isNFT(instanceId: BigNumber): boolean { + return !TokenInstance.isFungible(instanceId); + } +} diff --git a/chain-api/src/types/TokenMintAllowance.ts b/chain-api/src/types/TokenMintAllowance.ts new file mode 100644 index 000000000..c50e85cad --- /dev/null +++ b/chain-api/src/types/TokenMintAllowance.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { IsDefined, IsNotEmpty } from "class-validator"; + +import { BigNumberProperty, ChainKey } from "../utils"; +import { ChainObject } from "./ChainObject"; + +// Replaces singular TokenClass property totalMintAllowance +// Ledger entry specifying a totalQuantity of a new Mint GiveAllowance req +export class TokenMintAllowance extends ChainObject { + public static INDEX_KEY = "GCTMA"; + + @ChainKey({ position: 0 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 3 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 4 }) + @BigNumberProperty() + public totalKnownMintAllowancesAtRequest: BigNumber; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + public grantedBy: string; + + @ChainKey({ position: 6 }) + @IsNotEmpty() + public grantedTo: string; + + @ChainKey({ position: 7 }) + @IsNotEmpty() + public created: number; + + @IsNotEmpty() + public reqId: string; + + @BigNumberProperty() + public quantity: BigNumber; +} diff --git a/chain-api/src/types/TokenMintAllowanceRequest.ts b/chain-api/src/types/TokenMintAllowanceRequest.ts new file mode 100644 index 000000000..c239b351a --- /dev/null +++ b/chain-api/src/types/TokenMintAllowanceRequest.ts @@ -0,0 +1,184 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { BigNumber } from "bignumber.js"; +import { IsDefined, IsNotEmpty, IsOptional } from "class-validator"; + +import { TokenAllowance } from "../types/TokenAllowance"; +import { AllowanceType, TokenMintStatus } from "../types/common"; +import { BigNumberProperty, ChainKey } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; +import { ChainObject } from "./ChainObject"; +import { RangedChainObject } from "./RangedChainObject"; +import { TokenMintAllowance } from "./TokenMintAllowance"; + +export class TokenMintAllowanceRequest extends RangedChainObject { + public static INDEX_KEY = "GCTMAR"; + + @ChainKey({ position: 0 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 3 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 4 }) + @IsDefined() + public timeKey: string; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + public grantedTo: string; + + @IsNotEmpty() + @BigNumberProperty() + public totalKnownMintAllowancesCount: BigNumber; + + @IsNotEmpty() + public created: number; + + @IsNotEmpty() + public grantedBy: string; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; + + @IsNotEmpty() + public state: TokenMintStatus; + + @IsNotEmpty() + public id: string; + + @IsNotEmpty() + @BigNumberProperty() + public uses: BigNumber; + + @IsOptional() + public expires?: number; + + // todo: revisist epoch as chain key if/when fabric implements it beyond hard-coded 0 + // @ChainKey({ position: 4 }) + @IsNotEmpty() + public epoch: string; + + public requestId(): string { + const { + collection, + category, + type, + additionalKey, + totalKnownMintAllowancesCount, + created, + grantedBy, + grantedTo + } = this; + + return ChainObject.getStringKeyFromParts([ + collection, + category, + type, + additionalKey, + totalKnownMintAllowancesCount.toString(), + `${created}`, + grantedBy, + grantedTo + ]); + } + + public fulfillmentKey(): string { + const { + collection, + category, + type, + additionalKey, + totalKnownMintAllowancesCount, + created, + grantedBy, + grantedTo + } = this; + + return ChainObject.getCompositeKeyFromParts(TokenMintAllowance.INDEX_KEY, [ + collection, + category, + type, + additionalKey, + totalKnownMintAllowancesCount.toString(), + `${created}`, + grantedBy, + grantedTo + ]); + } + + public fulfill(instance: BigNumber): [TokenMintAllowance, TokenAllowance] { + const { + collection, + category, + type, + additionalKey, + totalKnownMintAllowancesCount, + created, + grantedBy, + grantedTo, + quantity, + id, + uses, + expires + } = this; + + const mintAllowanceEntry = new TokenMintAllowance(); + + mintAllowanceEntry.collection = collection; + mintAllowanceEntry.category = category; + mintAllowanceEntry.type = type; + mintAllowanceEntry.additionalKey = additionalKey; + mintAllowanceEntry.totalKnownMintAllowancesAtRequest = totalKnownMintAllowancesCount; + mintAllowanceEntry.grantedBy = grantedBy; + mintAllowanceEntry.grantedTo = grantedTo; + mintAllowanceEntry.created = created; + mintAllowanceEntry.reqId = id; + mintAllowanceEntry.quantity = quantity; + + const allowance = new TokenAllowance(); + + allowance.grantedTo = grantedTo; + allowance.collection = collection; + allowance.category = category; + allowance.type = type; + allowance.additionalKey = additionalKey; + allowance.instance = instance; + allowance.allowanceType = AllowanceType.Mint; + allowance.grantedBy = grantedBy; + // todo: determine if using the created timestamp of the request is fine, + // or if we need to use the timestamp of the fulfillment. + allowance.created = created; + allowance.uses = uses; + allowance.usesSpent = new BigNumber("0"); + allowance.expires = expires ?? 0; + allowance.quantity = quantity; + allowance.quantitySpent = new BigNumber("0"); + + return [mintAllowanceEntry, allowance]; + } +} diff --git a/chain-api/src/types/TokenMintFulfillment.ts b/chain-api/src/types/TokenMintFulfillment.ts new file mode 100644 index 000000000..2c441346a --- /dev/null +++ b/chain-api/src/types/TokenMintFulfillment.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { IsDefined, IsNotEmpty } from "class-validator"; + +import { TokenMintStatus } from "../types/common"; +import { BigNumberProperty, ChainKey } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; +import { ChainObject } from "./ChainObject"; + +export class TokenMintFulfillment extends ChainObject { + public static INDEX_KEY = "GCTMF"; + @ChainKey({ position: 0 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 3 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 4 }) + @IsNotEmpty() + public requestor: string; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + public requestCreated: number; + + @IsNotEmpty() + public owner: string; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; + + @IsNotEmpty() + public state: TokenMintStatus; + + @IsNotEmpty() + public id: string; + + @IsNotEmpty() + public created: number; +} diff --git a/chain-api/src/types/TokenMintRequest.ts b/chain-api/src/types/TokenMintRequest.ts new file mode 100644 index 000000000..f80976cda --- /dev/null +++ b/chain-api/src/types/TokenMintRequest.ts @@ -0,0 +1,143 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { IsDefined, IsNotEmpty, IsOptional } from "class-validator"; + +import { BigNumberProperty, ChainKey } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; +import { ChainObject } from "./ChainObject"; +import { RangedChainObject } from "./RangedChainObject"; +import { TokenMintFulfillment } from "./TokenMintFulfillment"; +import { AllowanceKey, TokenMintStatus } from "./common"; + +export class TokenMintRequest extends RangedChainObject { + public static INDEX_KEY = "GCTMR"; + public static OBJECT_TYPE = "TokenMintRequest"; // for contract.GetObjectsByPartialCompositeKey + + @ChainKey({ position: 0 }) + @IsNotEmpty() + public collection: string; + + @ChainKey({ position: 1 }) + @IsNotEmpty() + public category: string; + + @ChainKey({ position: 2 }) + @IsNotEmpty() + public type: string; + + @ChainKey({ position: 3 }) + @IsDefined() + public additionalKey: string; + + @ChainKey({ position: 4 }) + @IsNotEmpty() + public timeKey: string; + + @ChainKey({ position: 5 }) + @IsNotEmpty() + public owner: string; + + @IsNotEmpty() + @BigNumberProperty() + public totalKnownMintsCount: BigNumber; + + @IsNotEmpty() + public requestor: string; + + @IsNotEmpty() + public created: number; + + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + public quantity: BigNumber; + + @IsNotEmpty() + public state: TokenMintStatus; + + @IsNotEmpty() + public id: string; + + // todo: revisit epoch as chain key if/when fabric implements it beyond hard-coded 0 + // @ChainKey({ position: ? }) + @IsNotEmpty() + public epoch: string; + + @IsOptional() + @Type(() => AllowanceKey) + @IsNotEmpty() + public allowanceKey?: AllowanceKey; + + public requestId(): string { + const { collection, category, type, additionalKey, totalKnownMintsCount, requestor, owner, created } = + this; + + return ChainObject.getStringKeyFromParts([ + collection, + category, + type, + additionalKey, + totalKnownMintsCount.toString(), + requestor, + owner, + `${created}` + ]); + } + + public fulfillmentKey(): string { + const { collection, category, type, additionalKey, totalKnownMintsCount, requestor, owner, created } = + this; + + return ChainObject.getCompositeKeyFromParts(TokenMintFulfillment.INDEX_KEY, [ + collection, + category, + type, + additionalKey, + totalKnownMintsCount.toString(), + requestor, + owner, + `${created}` + ]); + } + + public fulfill(qty: BigNumber): TokenMintFulfillment { + const { collection, category, type, additionalKey, requestor, created, owner } = this; + + const mintFulfillment = new TokenMintFulfillment(); + + mintFulfillment.collection = collection; + mintFulfillment.category = category; + mintFulfillment.type = type; + mintFulfillment.additionalKey = additionalKey; + mintFulfillment.requestor = requestor; + mintFulfillment.requestCreated = created; + mintFulfillment.owner = owner; + mintFulfillment.created = created; + + mintFulfillment.quantity = qty; + + if (qty.isLessThan(this.quantity)) { + mintFulfillment.state = TokenMintStatus.PartiallyMinted; + } else { + mintFulfillment.state = TokenMintStatus.Minted; + } + + mintFulfillment.id = this.requestId(); + + return mintFulfillment; + } +} diff --git a/chain-api/src/types/UserProfile.ts b/chain-api/src/types/UserProfile.ts new file mode 100644 index 000000000..40963c491 --- /dev/null +++ b/chain-api/src/types/UserProfile.ts @@ -0,0 +1,34 @@ +/* + * 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 { IsNotEmpty } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { ChainObject } from "./ChainObject"; + +export class UserProfile extends ChainObject { + @JSONSchema({ + description: `Legacy caller id from user name or identifier derived from ethAddress for new users.` + }) + @IsNotEmpty() + alias: string; + + @JSONSchema({ + description: `Eth address of the user.` + }) + @IsNotEmpty() + ethAddress: string; +} + +export const UP_INDEX_KEY = "GCUP"; diff --git a/chain-api/src/types/allowance.ts b/chain-api/src/types/allowance.ts new file mode 100644 index 000000000..a1865917a --- /dev/null +++ b/chain-api/src/types/allowance.ts @@ -0,0 +1,484 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { + ArrayMaxSize, + ArrayNotEmpty, + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + Max, + Min, + ValidateIf, + ValidateNested +} from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { BigNumberProperty, EnumProperty } from "../utils"; +import { ArrayUniqueObjects, BigNumberIsInteger, BigNumberIsPositive } from "../validators"; +import { GrantAllowanceQuantity } from "./GrantAllowance"; +import { TokenAllowance } from "./TokenAllowance"; +import { TokenInstance, TokenInstanceKey, TokenInstanceQueryKey } from "./TokenInstance"; +import { AllowanceKey, AllowanceType, MintRequestDto } from "./common"; +import { ChainCallDTO } from "./dtos"; + +@JSONSchema({ + description: "Contains parameters for fetching allowances with pagination." +}) +export class FetchAllowancesDto extends ChainCallDTO { + static readonly MAX_LIMIT = 10 * 1000; + static readonly DEFAULT_LIMIT = 1000; + + @JSONSchema({ + description: "A user who can use an allowance." + }) + @IsNotEmpty() + grantedTo: string; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey. Optional, but required if instance is provided." + }) + @ValidateIf((o) => !!o.instance) + @IsNotEmpty() + additionalKey?: string; + + @JSONSchema({ + description: "Token instance. Optional, but required if allowanceType is provided" + }) + @ValidateIf((o) => o.allowanceType !== undefined) + @IsNotEmpty() + instance?: string; + + @IsOptional() + @EnumProperty(AllowanceType) + allowanceType?: AllowanceType; + + @JSONSchema({ + description: "User who granted allowances." + }) + @IsOptional() + @IsNotEmpty() + grantedBy?: string; + + @JSONSchema({ + description: "Page bookmark. If it is undefined, then the first page is returned." + }) + @IsOptional() + @IsNotEmpty() + bookmark?: string; + + @JSONSchema({ + description: + `Page size limit. ` + + `Defaults to ${FetchAllowancesDto.DEFAULT_LIMIT}, max possible value ${FetchAllowancesDto.MAX_LIMIT}. ` + + "Note you will likely get less results than the limit, because the limit is applied before additional filtering." + }) + @IsOptional() + @Max(FetchAllowancesDto.MAX_LIMIT) + @Min(1) + @IsInt() + limit?: number; +} + +@JSONSchema({ + description: + "Contains parameters for fetching allowances. " + + "Deprecated since 2023-05-29. Please use version with pagination." +}) +/** + * @deprecated + */ +export class FetchAllowancesLegacyDto extends ChainCallDTO { + @JSONSchema({ + description: "A user who can use an allowance." + }) + @IsNotEmpty() + grantedTo: string; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey. Optional, but required if instance is provided." + }) + @ValidateIf((o) => !!o.instance) + @IsNotEmpty() + additionalKey?: string; + + @JSONSchema({ + description: "Token instance. Optional, but required if allowanceType is provided" + }) + @ValidateIf((o) => o.allowanceType !== undefined) + @IsNotEmpty() + instance?: string; + + @IsOptional() + @EnumProperty(AllowanceType) + allowanceType?: AllowanceType; + + @JSONSchema({ + description: "User who granted allowances." + }) + @IsOptional() + @IsNotEmpty() + grantedBy?: string; + + @JSONSchema({ + description: "Page bookmark. If it is undefined, then the first page is returned." + }) + @IsOptional() + @IsNotEmpty() + bookmark?: string; +} + +export class FetchAllowancesResponse extends ChainCallDTO { + @JSONSchema({ description: "List of allowances." }) + @ValidateNested({ each: true }) + @Type(() => TokenAllowance) + results: TokenAllowance[]; + + @JSONSchema({ description: "Next page bookmark." }) + @IsOptional() + @IsNotEmpty() + nextPageBookmark?: string; +} + +@JSONSchema({ + description: "Contains parameters for deleting allowances for a calling user." +}) +export class DeleteAllowancesDto extends ChainCallDTO { + @JSONSchema({ + description: "A user who can use an allowance." + }) + @IsNotEmpty() + grantedTo: string; + + @JSONSchema({ + description: "User who granted allowances." + }) + @IsOptional() + @IsNotEmpty() + grantedBy?: string; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey. Optional, but required if instance is provided." + }) + @ValidateIf((o) => !!o.instance) + @IsNotEmpty() + additionalKey?: string; + + @JSONSchema({ + description: "Token instance. Optional, but required if allowanceType is provided" + }) + @ValidateIf((o) => o.allowanceType !== undefined) + @IsNotEmpty() + instance?: string; + + @IsOptional() + @EnumProperty(AllowanceType) + allowanceType?: AllowanceType; +} + +@JSONSchema({ + description: "Defines allowances to be created." +}) +export class GrantAllowanceDto extends ChainCallDTO { + static DEFAULT_EXPIRES = 0; + + @JSONSchema({ + description: + "Token instance of token which the allowance concerns. " + + "In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE}.` + }) + @ValidateNested() + @Type(() => TokenInstanceQueryKey) + @IsNotEmpty() + tokenInstance: TokenInstanceQueryKey; + + @JSONSchema({ + description: "List of objects with user and token quantities. " + "The user fields must be unique" + }) + @ValidateNested({ each: true }) + @Type(() => GrantAllowanceQuantity) + @ArrayNotEmpty() + @ArrayUniqueObjects("user") + quantities: Array; + + @IsNotEmpty() + @EnumProperty(AllowanceType) + allowanceType: AllowanceType; + + @JSONSchema({ + description: "How many times each allowance can be used." + }) + @BigNumberIsPositive() + @BigNumberIsInteger() + @BigNumberProperty() + uses: BigNumber; + + @JSONSchema({ + description: + "Unix timestamp of the date when the allowances should expire. 0 means that it won' expire. " + + `By default set to ${GrantAllowanceDto.DEFAULT_EXPIRES}.` + }) + @IsOptional() + expires?: number; +} + +/** + * Experimental: Defines allowances to be created. High-throughput implementation. + * + * @experimental 2023-03-23 + */ +@JSONSchema({ + description: + "Experimental: Defines allowances to be created. High-throughput implementation. " + + "DTO properties backwards-compatible with prior GrantAllowanceDto, with the " + + "exception that this implementation only supports AllowanceType.Mint." +}) +export class HighThroughputGrantAllowanceDto extends ChainCallDTO { + // todo: remove all these duplicated properties + // it seems something about our @GalaTransaction decorator does not pass through + // parent properties. Leaving this class empty with just the `extends GrantAllowanceDto` + // results in an api definition with no property except the signature. + // update: using extends GrantAllowanceDto causes issues with property validation and failure + static DEFAULT_EXPIRES = 0; + + @JSONSchema({ + description: + "Token instance of token which the allowance concerns. " + + "In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE}.` + }) + @ValidateNested() + @Type(() => TokenInstanceQueryKey) + @IsNotEmpty() + tokenInstance: TokenInstanceQueryKey; + + @JSONSchema({ + description: "List of objects with user and token quantities. " + "The user fields must be unique" + }) + @ValidateNested({ each: true }) + @Type(() => GrantAllowanceQuantity) + @ArrayNotEmpty() + @ArrayUniqueObjects("user") + quantities: Array; + + @IsNotEmpty() + @EnumProperty(AllowanceType) + allowanceType: AllowanceType; + + @JSONSchema({ + description: "How many times each allowance can be used." + }) + @BigNumberIsPositive() + @BigNumberIsInteger() + @BigNumberProperty() + uses: BigNumber; + + @JSONSchema({ + description: + "Unix timestamp of the date when the allowances should expire. 0 means that it won' expire. " + + `By default set to ${GrantAllowanceDto.DEFAULT_EXPIRES}.` + }) + @IsOptional() + expires?: number; +} + +@JSONSchema({ + description: + "Experimental: After submitting request to RequestMintAllowance, follow up with FulfillMintAllowance." +}) +export class FulfillMintAllowanceDto extends ChainCallDTO { + static MAX_ARR_SIZE = 1000; + + @ValidateNested({ each: true }) + @Type(() => MintRequestDto) + @ArrayNotEmpty() + @ArrayMaxSize(FulfillMintAllowanceDto.MAX_ARR_SIZE) + @ArrayUniqueObjects("id") + requests: MintRequestDto[]; +} + +@JSONSchema({ + description: + "Fetch one or more balances, verify all owned TokenInstances have at least one available " + + "allowance of the specified type. Any token instance key(s) with no available allowances will " + + "be returned in the response." +}) +export class FullAllowanceCheckDto extends ChainCallDTO { + @JSONSchema({ + description: "Person who owns the balance(s). If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: + "Person/UserId to whom allowance(s) were granted. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + grantedTo?: string; + + @JSONSchema({ + description: "Token collection. Optional." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, and ignored if collection is not provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, and ignored if category is not provded." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey. Optional, and ignored if type is not provided." + }) + @IsOptional() + additionalKey?: string; + + @JSONSchema({ + description: "AllowanceType to check. Default: Use (0)" + }) + @IsOptional() + @IsNotEmpty() + allowanceType?: AllowanceType; +} + +@JSONSchema({ + description: "Response Data Transfer Object for FullLockAllowance request." +}) +export class FullAllowanceCheckResDto extends ChainCallDTO { + @JSONSchema({ + description: "True if all resulting token(s) have active/un-expired allowances available." + }) + @IsBoolean() + all: boolean; + + @JSONSchema({ + description: "TokenInstanceKey(s) of any tokens missing the requested AllowanceType." + }) + @ValidateNested({ each: true }) + @Type(() => TokenInstanceKey) + @ArrayNotEmpty() + missing: Array; +} + +@JSONSchema({ + description: + "Refresh the uses or expiration date of an existing allowance. " + + "If quantity needs updating, grant a new allowance instead." +}) +export class RefreshAllowanceDto extends ChainCallDTO { + @Type(() => AllowanceKey) + @IsNotEmpty() + public allowanceKey: AllowanceKey; + + @BigNumberIsPositive() + @BigNumberIsInteger() + @BigNumberProperty() + public uses: BigNumber; + + @Min(0) + @IsInt() + public expires: number; +} + +@JSONSchema({ + description: + "Refresh the uses or expiration date of an existing allowance. " + + "If quantity needs updating, grant a new allowance instead." +}) +export class RefreshAllowancesDto extends ChainCallDTO { + @ValidateNested({ each: true }) + @Type(() => RefreshAllowanceDto) + @ArrayNotEmpty() + allowances: Array; +} diff --git a/chain-api/src/types/api.ts b/chain-api/src/types/api.ts new file mode 100644 index 000000000..71504baaf --- /dev/null +++ b/chain-api/src/types/api.ts @@ -0,0 +1,31 @@ +/* + * 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 { SchemaObject } from "openapi3-ts"; + +export interface MethodAPI { + methodName: string; + apiMethodName?: string; + isWrite: boolean; + description?: string; + dtoSchema?: SchemaObject; + responseSchema?: SchemaObject; + sequence?: MethodAPI[]; +} + +export interface ContractAPI { + contractVersion: string; + contractName: string; + methods: MethodAPI[]; +} diff --git a/chain-api/src/types/burn.ts b/chain-api/src/types/burn.ts new file mode 100644 index 000000000..490aff002 --- /dev/null +++ b/chain-api/src/types/burn.ts @@ -0,0 +1,276 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsDefined, + IsInt, + IsNotEmpty, + IsOptional, + IsPositive, + Max, + Min, + ValidateIf, + ValidateNested +} from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { BigNumberProperty } from "../utils"; +import { BigNumberIsInteger, BigNumberIsNotNegative } from "../validators"; +import { BurnTokenQuantity } from "./BurnTokenQuantity"; +import { TokenBurnCounter } from "./TokenBurnCounter"; +import { TokenInstance } from "./TokenInstance"; +import { ChainCallDTO } from "./dtos"; +import { BatchMintTokenDto } from "./mint"; + +@JSONSchema({ + description: "Contains parameters for fetching burns." +}) +export class FetchBurnsDto extends ChainCallDTO { + @JSONSchema({ + description: "The user who burned the token." + }) + @IsNotEmpty() + burnedBy: string; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey. Optional, but required if instance is provided." + }) + @ValidateIf((o) => !!o.instance) + @IsNotEmpty() + additionalKey?: string; + + @JSONSchema({ + description: "Token instance. Optional, but required if allowanceType is provided." + }) + @ValidateIf((o) => o.allowanceType !== undefined) + @IsNotEmpty() + instance?: string; + + @JSONSchema({ + description: "Created time. Optional." + }) + @IsPositive() + @IsInt() + @IsOptional() + public created?: number; +} + +@JSONSchema({ + description: "Defines burns to be created." +}) +export class BurnTokensDto extends ChainCallDTO { + @JSONSchema({ + description: + "Array of token instances of token to be burned. In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE} and quantity set to 1.` + }) + @ValidateNested({ each: true }) + @Type(() => BurnTokenQuantity) + @ArrayNotEmpty() + tokenInstances: Array; + + @JSONSchema({ + description: + "Owner of the tokens to be burned. If not provided, the calling user is assumed to be the owner." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; +} + +@JSONSchema({ + description: + "Permits an atomic burn-to-mint transaction. Supply the token(s) to be burned, and the token(s) to be minted. " + + "The `burnDto` and `mintDto` properties should be signed by their respective approving parties: " + + "As an example for NFTs, the `burnDto` might be signed by the end user that owns the tokens, while " + + "the mintDto is signed by an NFT token authority with the ability to mint NFTs. " + + "If the burn is successful, mint the requested token(s)." + + "Mints are executed under the identity of the calling user of this function. " + + "All operations occur in the same transaction, meaning either all succeed or none are written to chain." +}) +export class BurnAndMintDto extends ChainCallDTO { + static MAX_ARR_SIZE = 1000; + + @JSONSchema({ + description: "A valid BurnTokensDto, properly signed by the owner of the tokens to be burned." + }) + @ValidateNested() + @Type(() => BurnTokensDto) + @IsNotEmpty() + burnDto: BurnTokensDto; + + @JSONSchema({ + description: + "User ID of the identity that owns the tokens to be burned. " + + "The burnDto signature will be validated against this user's public key on chain." + }) + @IsNotEmpty() + burnOwner: string; + + @JSONSchema({ + description: "DTOs of tokens to mint." + }) + @ValidateNested() + @Type(() => BatchMintTokenDto) + @IsNotEmpty() + mintDto: BatchMintTokenDto; +} + +@JSONSchema({ + description: "Contains parameters for fetching TokenBurnCounters with pagination." +}) +export class FetchBurnCountersWithPaginationDto extends ChainCallDTO { + static readonly MAX_LIMIT = 10 * 1000; + static readonly DEFAULT_LIMIT = 1000; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey. Optional, but required if instance is provided." + }) + @ValidateIf((o) => !!o.instance) + @IsNotEmpty() + additionalKey?: string; + + @JSONSchema({ + description: "Page bookmark. If it is undefined, then the first page is returned." + }) + @IsOptional() + @IsNotEmpty() + bookmark?: string; + + @JSONSchema({ + description: + `Page size limit. ` + + `Defaults to ${FetchBurnCountersWithPaginationDto.DEFAULT_LIMIT}, max possible value ${FetchBurnCountersWithPaginationDto.MAX_LIMIT}. ` + + "Note you will likely get less results than the limit, because the limit is applied before additional filtering." + }) + @IsOptional() + @Max(FetchBurnCountersWithPaginationDto.MAX_LIMIT) + @Min(1) + @IsInt() + limit?: number; +} + +export class FetchBurnCountersResponse extends ChainCallDTO { + @JSONSchema({ description: "List of token burn counters." }) + @ValidateNested({ each: true }) + @Type(() => TokenBurnCounter) + results: TokenBurnCounter[]; + + @JSONSchema({ description: "Next page bookmark." }) + @IsOptional() + @IsNotEmpty() + nextPageBookmark?: string; +} + +@JSONSchema({ + description: "Key properties representing a TokenBurnCounter." +}) +export class TokenBurnCounterCompositeKeyDto extends ChainCallDTO { + @JSONSchema({ + description: "Token collection." + }) + @IsNotEmpty() + collection: string; + + @JSONSchema({ + description: "Token category." + }) + @IsNotEmpty() + category: string; + @JSONSchema({ + description: "Token type." + }) + @IsNotEmpty() + type: string; + @JSONSchema({ + description: "Token additionalKey." + }) + @IsDefined() + additionalKey: string; + + @JSONSchema({ + description: "timeKey of TokenBurnCounter for range reads" + }) + @IsNotEmpty() + timeKey: string; + + @JSONSchema({ + description: "burnedBy user." + }) + @IsNotEmpty() + burnedBy: string; + + @JSONSchema({ + description: "Token instance." + }) + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + instance: BigNumber; + + @JSONSchema({ + description: + "Known burn counts at time of write, " + + "discounting concurrent writes that occurred in the same block.." + }) + @IsNotEmpty() + @BigNumberProperty() + totalKnownBurnsCount: BigNumber; +} diff --git a/chain-api/src/types/ccp.ts b/chain-api/src/types/ccp.ts new file mode 100644 index 000000000..ea3779d5c --- /dev/null +++ b/chain-api/src/types/ccp.ts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +export interface ICCP { + name: string; + version: string; + client: { + organization: string; + connection: { + timeout: { + peer: { + endorser: string; + }; + }; + }; + }; + organizations: { + [orgName: string]: { + mspid: string; + peers: string[]; + certificateAuthorities: string[]; + }; + }; + peers: { + [hostname: string]: { + url: string; + tlsCACerts: { + pem: string; + }; + grpcOptions: { + string: string; + hostnameOverride: string; + }; + }; + }; + certificateAuthorities: { + [hostname: string]: { + url: string; + caName: string; + tlsCACerts: { + pem: string[]; + }; + httpOptions: { + verify: false; + }; + }; + }; +} diff --git a/chain-api/src/types/common.ts b/chain-api/src/types/common.ts new file mode 100644 index 000000000..a77e5ac23 --- /dev/null +++ b/chain-api/src/types/common.ts @@ -0,0 +1,119 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { IsDefined, IsInt, IsNotEmpty, IsOptional, IsPositive } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { BigNumberProperty, EnumProperty } from "../utils/transform-decorators"; +import { BigNumberIsInteger, BigNumberIsNotNegative } from "../validators/decorators"; +import { ChainCallDTO } from "./dtos"; + +export enum AllowanceType { + Use = 0, + Lock = 1, + // Note: We may want to remove this in the future, as Spend is redundant with transfer allowance + Spend = 2, + Transfer = 3, + Mint = 4, + Swap = 5, + Burn = 6 +} + +@JSONSchema({ + description: "Key fields that identity an existing TokenAllowance." +}) +export class AllowanceKey extends ChainCallDTO { + @IsNotEmpty() + public grantedTo: string; + + @IsNotEmpty() + public collection: string; + + @IsNotEmpty() + public category: string; + + @IsNotEmpty() + public type: string; + + @IsDefined() + public additionalKey: string; + + @IsNotEmpty() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + public instance: BigNumber; + + @EnumProperty(AllowanceType) + public allowanceType: AllowanceType; + + @IsNotEmpty() + public grantedBy: string; + + @IsPositive() + @IsInt() + public created: number; +} + +export enum TokenMintStatus { + Unknown, + Minted, + PartiallyMinted, + AllowanceTotalExceeded, + SupplyTotalExceeded, + NullAdministrativePatchEntry +} + +// todo: with various other class definitions moving out of common.ts to fix circular dependencies, +// consider where a better home for this definition could be. +@JSONSchema({ description: "Minimal property set represnting a mint request." }) +export class MintRequestDto { + @IsNotEmpty() + public collection: string; + + @IsNotEmpty() + public category: string; + + @IsNotEmpty() + public type: string; + + @IsNotEmpty() + public additionalKey: string; + + @IsNotEmpty() + public timeKey: string; + + @BigNumberProperty() + public totalKnownMintsCount: BigNumber; + + @IsNotEmpty() + public id: string; + + @JSONSchema({ + description: "The owner of minted tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: "(Optional). Specify the TokenAllowance on chain to use for this mint." + }) + @IsOptional() + @Type(() => AllowanceKey) + @IsNotEmpty() + public allowanceKey?: AllowanceKey; +} diff --git a/chain-api/src/types/contract.spec.ts b/chain-api/src/types/contract.spec.ts new file mode 100644 index 000000000..3163dd964 --- /dev/null +++ b/chain-api/src/types/contract.spec.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; + +import { BigNumberProperty, NotFoundError } from "../utils"; +import { ChainObject } from "./ChainObject"; +import { GalaChainResponse } from "./contract"; + +class DummyClass extends ChainObject { + @BigNumberProperty() + maxSupply: BigNumber; +} + +it("should deserialize response", () => { + // Given + const responseString = `{"Status":1,"Data":{"collection":"Platform","category":"Currency","type":"GALA","additionalKey":"none","network":"GC","totalMintAllowance":"0","maxSupply":"50000000000","maxCapacity":"50000000000","authorities":["org1|curatorUser"],"name":"GALA","symbol":"GALA","description":"This is a test description!","image":"https://app.gala.games/_nuxt/img/gala-logo_horizontal_white.8b0409c.png","isNonFungible":false,"totalBurned":"0","totalSupply":"0","decimals":8}}`; + + // When + const deserialized = GalaChainResponse.deserialize(DummyClass, responseString); + + // Then + expect(deserialized.Data?.maxSupply).toEqual(new BigNumber("50000000000")); +}); + +it("should deserialize array response", async () => { + // Given + const responseString = `{"Status":1,"Data":[{"collection":"Platform","category":"Currency","type":"GALA","additionalKey":"none","network":"GC","totalMintAllowance":"0","maxSupply":"50000000000","maxCapacity":"50000000000","authorities":["org1|curatorUser"],"name":"GALA","symbol":"GALA","description":"This is a test description!","image":"https://app.gala.games/_nuxt/img/gala-logo_horizontal_white.8b0409c.png","isNonFungible":false,"totalBurned":"0","totalSupply":"0","decimals":8}]}`; + + // When + const deserialized: GalaChainResponse = GalaChainResponse.deserialize( + DummyClass, + responseString + ); + + // Then + expect(deserialized.Data?.[0]?.maxSupply).toEqual(new BigNumber("50000000000")); +}); + +it("should deserialize single string response", () => { + // Given + const responseString = `{"Status":1,"Data":"GALA"}`; + + // When + const deserialized = GalaChainResponse.deserialize(String, responseString); + + // Then + expect(deserialized.Data).toEqual("GALA"); +}); + +it("should deserialize string array response", () => { + // Given + const responseString = `{"Status":1,"Data":["GALA1","GALA2"]}`; + + // When + // eslint-disable-next-line @typescript-eslint/ban-types + const deserialized = GalaChainResponse.deserialize(String, responseString); + + // Then + expect(deserialized.Data).toEqual(["GALA1", "GALA2"]); +}); + +it("should deserialize error response", () => { + // Given + const responseString = `{"Status":0,"Message":"No object found","ErrorCode":404,"ErrorKey":"NOT_FOUND"}`; + + // When + const deserialized = GalaChainResponse.deserialize(DummyClass, responseString); + + // Then + expect(deserialized).toEqual(GalaChainResponse.Error(new NotFoundError("No object found"))); +}); diff --git a/chain-api/src/types/contract.ts b/chain-api/src/types/contract.ts new file mode 100644 index 000000000..8976cf169 --- /dev/null +++ b/chain-api/src/types/contract.ts @@ -0,0 +1,137 @@ +/* + * 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 { deserialize } from "../utils"; +import { ChainError, ErrorCode } from "../utils/error"; +import { ClassConstructor, Inferred } from "./dtos"; + +export const GC_NETWORK_ID = "GC"; +export enum GalaChainResponseType { + Error, + Success +} + +export abstract class GalaChainResponse { + public readonly Status: GalaChainResponseType; + public readonly Message?: string; + public readonly ErrorCode?: number; + public readonly ErrorKey?: string; + public readonly ErrorPayload?: unknown; + public readonly Data?: T; + public static Success(Data: T): GalaChainResponse { + return new GalaChainSuccessResponse(Data); + } + public static Error(e: { message?: string }): GalaChainResponse; + public static Error(e: ChainError): GalaChainResponse; + public static Error( + Message: string, + ErrorCode: number, + ErrorKey: string, + ErrorPayload?: Record + ): GalaChainResponse; + + public static Error( + MessageOrError: string | { message?: string }, + ErrorCode?: number, + ErrorKey?: string, + ErrorPayload?: Record + ): GalaChainResponse { + if (typeof MessageOrError === "string") { + return new GalaChainErrorResponse(MessageOrError, ErrorCode, ErrorKey, ErrorPayload); + } else { + return new GalaChainErrorResponse(MessageOrError); + } + } + + public static Wrap(op: Promise): Promise> { + return op + .then((Data) => GalaChainResponse.Success(Data)) + .catch((e: { message?: string }) => GalaChainResponse.Error(e)); + } + + public static isSuccess(r: GalaChainResponse): r is GalaChainSuccessResponse { + return r.Status === GalaChainResponseType.Success; + } + + public static isError(r: GalaChainResponse): r is GalaChainErrorResponse { + return r.Status === GalaChainResponseType.Error; + } + + public static deserialize( + constructor: ClassConstructor> | undefined, + object: string | Record + ): GalaChainResponse { + const json = typeof object === "string" ? JSON.parse(object) : object; + if (json.Status === GalaChainResponseType.Error) { + return deserialize>(GalaChainErrorResponse, json); + } else if (constructor === undefined) { + // TODO we are cheating somewhat with response type, fix with method overloading + return deserialize(GalaChainSuccessResponse, json) as GalaChainResponse; + } else { + // nested objects might be not deserialized properly for generics, that's why we deserialize `Data` again + const data = + typeof json.Data === "object" + ? deserialize(constructor, (json.Data ?? {}) as Record) + : json.Data; + return new GalaChainSuccessResponse(data); + } + } +} + +export class GalaChainErrorResponse extends GalaChainResponse { + public readonly Status: GalaChainResponseType.Error; + public readonly Message: string; + public readonly ErrorCode: number; + public readonly ErrorKey: string; + public readonly ErrorPayload?: Record; + + constructor(message: string, errorCode?: number, errorKey?: string, errorPayload?: Record); + + constructor(error: { message?: string }); + + constructor(error: ChainError); + + constructor( + messageOrError: string | { message?: string }, + errorCode?: number, + errorKey?: string, + errorPayload?: Record + ) { + super(); + if (typeof messageOrError === "string") { + this.Status = GalaChainResponseType.Error; + this.Message = messageOrError; + this.ErrorCode = errorCode ?? ErrorCode.DEFAULT_ERROR; + this.ErrorKey = errorKey ?? "UNKNOWN"; + this.ErrorPayload = errorPayload; + } else { + const chainError = ChainError.from(messageOrError); + this.Status = GalaChainResponseType.Error; + this.Message = chainError.message; + this.ErrorCode = chainError.code; + this.ErrorKey = chainError.key; + this.ErrorPayload = chainError.payload; + } + } +} + +export class GalaChainSuccessResponse extends GalaChainResponse { + public readonly Status: GalaChainResponseType.Success; + public readonly Data: T; + constructor(data: T) { + super(); + this.Status = GalaChainResponseType.Success; + this.Data = data; + } +} diff --git a/chain-api/src/types/dtos.spec.ts b/chain-api/src/types/dtos.spec.ts new file mode 100644 index 000000000..cc54f0871 --- /dev/null +++ b/chain-api/src/types/dtos.spec.ts @@ -0,0 +1,186 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { classToPlain as instanceToPlain, plainToClass as plainToInstance } from "class-transformer"; +import { ArrayMinSize, ArrayNotEmpty, IsString } from "class-validator"; +import { ec as EC } from "elliptic"; + +import { BigNumberArrayProperty, BigNumberProperty, getValidationErrorInfo } from "../utils"; +import { ValidationErrorInfo } from "../utils/getValidationErrorMessage"; +import { ChainCallDTO, ClassConstructor } from "./dtos"; + +const getInstanceOrErrorInfo = async ( + constructor: ClassConstructor, + jsonString: string +): Promise => { + try { + const deserialized = plainToInstance(constructor, JSON.parse(jsonString)); // note: throws exception here if JSON is invalid + const validationErrors = await deserialized.validate(); + if (validationErrors.length) { + return getValidationErrorInfo(validationErrors); + } else { + return deserialized; + } + } catch (e) { + return e.message; + } +}; + +const getPlainOrError = async ( + constructor: ClassConstructor, + jsonString: string +): Promise | string> => { + const instance = await getInstanceOrErrorInfo(constructor, jsonString); + return typeof instance === "string" ? instance : instanceToPlain(instance); +}; + +class TestDtoWithArray extends ChainCallDTO { + @IsString({ each: true }) + @ArrayMinSize(1) + playerIds: Array; +} + +class TestDtoWithBigNumber extends ChainCallDTO { + @BigNumberProperty() + quantity: BigNumber; +} + +it("should parse TestDtoWithArray", async () => { + const valid = '{"playerIds":["123"]}'; + const invalid1 = '{"playerIds":[]}'; + const invalid2 = '{"playerId":"aaa"}'; + const invalid3 = '{"invalid":"json'; + + const failedArrayMatcher = expect.objectContaining({ + message: expect.stringContaining("has failed the following constraints: arrayMinSize") + }); + + expect(await getPlainOrError(TestDtoWithArray, valid)).toEqual({ playerIds: ["123"] }); + expect(await getPlainOrError(TestDtoWithArray, invalid1)).toEqual(failedArrayMatcher); + expect(await getPlainOrError(TestDtoWithArray, invalid2)).toEqual(failedArrayMatcher); + expect(await getPlainOrError(TestDtoWithArray, invalid3)).toEqual("Unexpected end of JSON input"); +}); + +it("should parse TestDtoWithBigNumber", async () => { + const valid = '{"quantity":"123"}'; + const invalid1 = '{"quantity":123}'; + const invalid2 = '{"quantity":"123.10"}'; + const invalid3 = '{"quantity":"1.23e+5"}'; + const invalid4 = '{"quantity":"aaa"}'; + + const expectedErrorPart = + "should be a stringified number with fixed notation (not an exponential notation) " + + "and no trailing zeros in decimal part"; + + expect(await getInstanceOrErrorInfo(TestDtoWithBigNumber, valid)).toEqual({ quantity: new BigNumber(123) }); + + expect(await getInstanceOrErrorInfo(TestDtoWithBigNumber, invalid1)).toEqual( + expect.objectContaining({ details: [expect.stringContaining(`${expectedErrorPart} (valid value: 123)`)] }) + ); + + expect(await getInstanceOrErrorInfo(TestDtoWithBigNumber, invalid2)).toEqual( + expect.objectContaining({ + details: [expect.stringContaining(`${expectedErrorPart} (valid value: 123.1)`)] + }) + ); + + expect(await getInstanceOrErrorInfo(TestDtoWithBigNumber, invalid3)).toEqual( + expect.objectContaining({ + details: [expect.stringContaining(`${expectedErrorPart} (valid value: 123000)`)] + }) + ); + + expect(await getInstanceOrErrorInfo(TestDtoWithBigNumber, invalid4)).toEqual( + expect.objectContaining({ details: [expect.stringContaining(expectedErrorPart)] }) + ); +}); + +describe("ChainCallDTO", () => { + class TestDto extends ChainCallDTO { + @BigNumberArrayProperty() + @ArrayNotEmpty() + amounts: Array; + + key?: string; + } + + function genKeyPair() { + const pair = new EC("secp256k1").genKeyPair(); + return { + privateKey: pair.getPrivate().toString("hex"), + publicKey: Buffer.from(pair.getPublic().encode("array", true)).toString("hex") + }; + } + it("should sign and verify signature", () => { + // Given + const { privateKey, publicKey } = genKeyPair(); + const dto = new TestDto(); + dto.amounts = [new BigNumber("12.3")]; + expect(dto.signature).toEqual(undefined); + + // When + dto.sign(privateKey); + + // Then + expect(dto.signature).toEqual(expect.stringMatching(/.{50,}/)); + expect(dto.isSignatureValid(publicKey)).toEqual(true); + }); + + it("should sign and verify signature (edge case - shorter private key with missing trailing 0)", () => { + // Given + const privateKey = "e8d506db1e7c8d98dbc6752537939312702962f48e169084a7babbb5c96217f"; + const publicKey = "0365bc56f0a623867746cbb025a74c295b5f794cf7c4adc11991bad1522912e5f6"; + expect(privateKey.length).toEqual(63); // shorter than regular 64 one + + const dto = new TestDto(); + dto.amounts = [new BigNumber("12.3")]; + expect(dto.signature).toEqual(undefined); + + // When + dto.sign(privateKey); + + // Then + expect(dto.signature).toEqual(expect.stringMatching(/.{50,}/)); + expect(dto.isSignatureValid(publicKey)).toEqual(true); + }); + + it("should sign and fail to verify signature (invalid key)", () => { + // Given + const { privateKey } = genKeyPair(); + const invalid = genKeyPair(); + const dto = new TestDto(); + dto.amounts = [new BigNumber("12.3")]; + + // When + dto.sign(privateKey); + + // Then + expect(dto.isSignatureValid(invalid.publicKey)).toEqual(false); + }); + + it("should sign and fail to verify signature (invalid payload)", () => { + // Given + const { privateKey, publicKey } = genKeyPair(); + const dto = new TestDto(); + dto.amounts = [new BigNumber("12.3")]; + + // When + dto.sign(privateKey); + dto.key = "i-will-break-this"; + + // Then + expect(dto.isSignatureValid(publicKey)).toEqual(false); + }); +}); diff --git a/chain-api/src/types/dtos.ts b/chain-api/src/types/dtos.ts new file mode 100644 index 000000000..d66f3de3d --- /dev/null +++ b/chain-api/src/types/dtos.ts @@ -0,0 +1,250 @@ +/* + * 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 { instanceToInstance, plainToInstance } from "class-transformer"; +import { IsNotEmpty, IsOptional, ValidationError, validate } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { ValidationFailedError, deserialize, getValidationErrorInfo, serialize, signatures } from "../utils"; + +type Base = T extends BaseT ? T : never; + +// `any` is specified on purpose to avoid some compilation errors when `unknown` is provided here +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Inferred = T extends (infer U)[] ? Base : Base; + +export interface ClassConstructor { + new (...args: unknown[]): T; +} + +class DtoValidationFailedError extends ValidationFailedError { + constructor({ message, details }: { message: string; details: string[] }) { + super(message, details); + } +} + +export const validateDTO = async (dto: T): Promise => { + const validationErrors = await dto.validate(); + + if (validationErrors.length) { + throw new DtoValidationFailedError(getValidationErrorInfo(validationErrors)); + } else { + return dto; + } +}; + +/** + * Parses JSON string and creates a Promise with valid DTO. Throws exception in case of validation errors. + */ +export const parseValidDTO = async ( + constructor: ClassConstructor>, + jsonStringOrObj: string | Record +): Promise => { + const deserialized = ChainCallDTO.deserialize(constructor, jsonStringOrObj); + await validateDTO(deserialized); + + return deserialized; +}; + +// eslint-disable-next-line @typescript-eslint/ban-types +type NonFunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T]; + +export type NonFunctionProperties = Pick>; + +/** + * Creates valid DTO object from provided plain object. Throws exception in case of validation errors. + */ +export const createValidDTO = async ( + constructor: ClassConstructor, + plain: NonFunctionProperties +): Promise => { + const instance = plainToInstance(constructor, plain) as T; + await validateDTO(instance); + return instance; +}; + +/** + * Creates valid signed DTO object from provided plain object. Throws exception in case of validation errors. + * + * @deprecated Use `(await createValidDTO(...)).signed(...)` instead + */ +export const createAndSignValidDTO = async ( + constructor: ClassConstructor, + plain: NonFunctionProperties, + privateKey: string +): Promise => { + const instance = plainToInstance(constructor, plain) as T; + instance.sign(privateKey); + await validateDTO(instance); + return instance; +}; + +export interface TraceContext { + spanId: string; + traceId: string; +} + +export class ChainCallDTO { + public trace?: TraceContext; + public static readonly ENCODING = "base64"; + + @JSONSchema({ + 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.\n" + + "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." + }) + @IsNotEmpty() + @IsOptional() + public uniqueKey?: string; + + @JSONSchema({ + 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.\n" + + "JSON payload to be signed is created from an object without 'signature' and 'trace` properties, " + + "and it's keys should be sorted alphabetically and no end of line at the end. " + + 'Sample jq command to produce valid data to sign: `jq -cSj "." dto-file.json`.' + + "Also all BigNumber data should be provided as strings (not numbers) with fixed decimal point notation.\n" + + "The EC secp256k1 signature should be created for keccak256 hash of the data. " + + "The recommended format of the signature is a HEX encoded string, including r + s + v values. " + + "Signature in this format is supported by ethers.js library. " + + "Sample signature: b7244d62671319583ea8f30c8ef3b343cf28e7b7bd56e32b21a5920752dc95b94a9d202b2919581bcf776f0637462cb67170828ddbcc1ea63505f6a211f9ac5b1b\n" + + "This field can also contain a DER encoded signature, but this is deprecated and supported only to provide backwards compatibility. " + + "DER encoded signature cannot be used recover user's public key from the signature, " + + "and cannot be used with the new signature-based authorization flow for Gala Chain.\n" + }) + @IsOptional() + @IsNotEmpty() + public signature?: string; + + @JSONSchema({ + description: + "Public key of the user who signed the DTO. " + + "Required for DER encoded signatures, since they miss recovery part." + }) + @IsOptional() + @IsNotEmpty() + public signerPublicKey?: string; + + validate(): Promise { + return validate(this); + } + + async validateOrReject(): Promise { + const validationErrors = await this.validate(); + + if (validationErrors.length) { + throw new DtoValidationFailedError(getValidationErrorInfo(validationErrors)); + } + } + + serialize(): string { + return serialize(this); + } + + public static deserialize( + constructor: ClassConstructor>, + object: string | Record | Record[] + ): T { + return deserialize(constructor, object); + } + + public sign(privateKey: string, useDer = false): void { + const keyBuffer = signatures.normalizePrivateKey(privateKey); + this.signature = useDer + ? signatures.getDERSignature(this, keyBuffer) + : signatures.getSignature(this, keyBuffer); + } + + /** + * Creates a signed copy of current object. + */ + // note: previously it was typed as "typeof this", but it's failed randomly on compilation + public signed(privateKey: string, useDer = false) { + const copied = instanceToInstance(this); + copied.sign(privateKey, useDer); + return copied; + } + + public isSignatureValid(publicKey: string): boolean { + return signatures.isValid(this.signature ?? "", this, publicKey); + } +} + +export class GetObjectDto extends ChainCallDTO { + @IsNotEmpty() + public readonly objectId: string; +} + +export class GetObjectHistoryDto extends ChainCallDTO { + @IsNotEmpty() + public readonly objectId: string; +} + +const publicKeyDescription = + "A public key to be saved on chain.\n" + + `It should be just the private part of the EC secp256k1 key, than can be retrieved this way: ` + + "`openssl ec -in priv-key.pem -text | grep pub -A 5 | tail -n +2 | tr -d '\\n[:space:]:`. " + + "The previous command produces an uncompressed hex string, but you can also provide an compressed one, " + + `as well as compressed and uncompressed base64 secp256k1 key. ` + + `A secp256k1 public key is saved on chain as compressed base64 string.`; + +@JSONSchema({ + description: `Dto for secure method to save public keys for legacy users. Method is called and signed by Curators` +}) +export class RegisterUserDto extends ChainCallDTO { + @JSONSchema({ + description: `Id of user to save public key for.` + }) + @IsNotEmpty() + user: string; + + @JSONSchema({ description: publicKeyDescription }) + @IsNotEmpty() + publicKey: string; +} + +@JSONSchema({ + description: `Dto for secure method to save public keys for Eth users. Method is called and signed by Curators` +}) +export class RegisterEthUserDto extends ChainCallDTO { + @JSONSchema({ description: publicKeyDescription }) + @IsNotEmpty() + publicKey: string; +} + +export class UpdatePublicKeyDto extends ChainCallDTO { + @JSONSchema({ description: publicKeyDescription }) + @IsNotEmpty() + publicKey: string; +} + +export class GetPublicKeyDto extends ChainCallDTO { + @JSONSchema({ + description: `Id of a public key holder. Optional field, by default caller's public key is returned.` + }) + @IsOptional() + user?: string; +} + +export class GetMyProfileDto extends ChainCallDTO { + // make signature required + @IsNotEmpty() + signature: string; +} diff --git a/chain-api/src/types/index.ts b/chain-api/src/types/index.ts new file mode 100644 index 000000000..e9d013aa1 --- /dev/null +++ b/chain-api/src/types/index.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ + +export * from "./api"; +export * from "./ccp"; +export * from "./ChainObject"; +export * from "./RangedChainObject"; +export * from "./contract"; +export * from "./dtos"; +export * from "./logger"; +export * from "./PublicKey"; +export * from "./UserProfile"; +export * from "./TokenInstance"; +export * from "./TokenClass"; +export * from "./token"; +export * from "./common"; +export * from "./AuthorizedOnBehalf"; +export * from "./TokenAllowance"; +export * from "./allowance"; +export * from "./GrantAllowance"; +export * from "./TokenBalance"; +export * from "./TokenClaim"; +export * from "./TokenBurn"; +export * from "./TokenBurnCounter"; +export * from "./BurnTokenQuantity"; +export * from "./LockTokenQuantity"; +export * from "./lock"; +export * from "./use"; +export * from "./burn"; +export * from "./mint"; +export * from "./TokenMintAllowance"; +export * from "./TokenMintAllowanceRequest"; +export * from "./TokenMintFulfillment"; +export * from "./TokenMintRequest"; +export * from "./TokenBurnCounter"; diff --git a/chain-api/src/types/lock.ts b/chain-api/src/types/lock.ts new file mode 100644 index 000000000..f48b04463 --- /dev/null +++ b/chain-api/src/types/lock.ts @@ -0,0 +1,157 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { ArrayNotEmpty, IsInt, IsNotEmpty, IsOptional, IsString, Min, ValidateNested } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { TokenInstance, TokenInstanceKey } from "../types/TokenInstance"; +import { ChainCallDTO } from "../types/dtos"; +import { BigNumberProperty } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; +import { LockTokenQuantity } from "./LockTokenQuantity"; + +@JSONSchema({ + description: "Describes an action to lock a token." +}) +export class LockTokenDto extends ChainCallDTO { + @JSONSchema({ + description: "The current owner of tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + 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." + }) + @IsNotEmpty() + @IsOptional() + lockAuthority?: string; + + @JSONSchema({ + description: + "Token instance of token to be locked. In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE}.` + }) + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + tokenInstance: TokenInstanceKey; + + @JSONSchema({ + description: "The quantity of token units to be locked." + }) + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; + + @JSONSchema({ + description: "Allowance ids to be used on lock (optional)." + }) + @IsString({ each: true }) + @IsOptional() + @ArrayNotEmpty() + useAllowances?: Array; +} + +@JSONSchema({ + description: "Describes an action to lock multiple tokens." +}) +export class LockTokensDto extends ChainCallDTO { + @JSONSchema({ + 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." + }) + @IsNotEmpty() + @IsOptional() + lockAuthority?: string; + + @JSONSchema({ + description: + "Array of token instances of token to be locked. In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE}.` + }) + @ArrayNotEmpty() + @Type(() => LockTokenQuantity) + @ValidateNested({ each: true }) + tokenInstances: Array; + + @JSONSchema({ + description: "Allowance ids to be used on lock (optional)." + }) + @IsString({ each: true }) + @IsOptional() + @ArrayNotEmpty() + useAllowances?: Array; + + @JSONSchema({ + description: + "Name for the token holds (optional). This name will be applied to all token holds created by this Lock." + }) + @IsString() + @IsOptional() + name?: string; + + @JSONSchema({ + description: + "Expiration timestamp. The TokenHold will expire at this time. This name will be applied to all token holds created by this Lock." + }) + @Min(0) + @IsInt() + @IsOptional() + public expires?: number; +} + +@JSONSchema({ + description: "Describes an action to unlock a token." +}) +export class UnlockTokenDto extends ChainCallDTO { + @JSONSchema({ + description: "Token instance of token to be unlocked." + }) + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + tokenInstance: TokenInstanceKey; +} + +@JSONSchema({ + description: "Describes an action to unlock multiple tokens." +}) +export class UnlockTokensDto extends ChainCallDTO { + @JSONSchema({ + description: + "Array of token instances of token to be locked. In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE}.` + }) + @ArrayNotEmpty() + @Type(() => LockTokenQuantity) + @ValidateNested({ each: true }) + tokenInstances: Array; + + @JSONSchema({ + description: + "Name for the token holds (optional). Only token holds with this name will be Unlocked if provided." + }) + @IsString() + @IsOptional() + name?: string; +} diff --git a/chain-api/src/types/logger.ts b/chain-api/src/types/logger.ts new file mode 100644 index 000000000..6947818f0 --- /dev/null +++ b/chain-api/src/types/logger.ts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +export interface ILoggerCommons { + context: IContextDetails; + process: IProcessDetails; + request?: IRequestDetails; + response?: IResponseDetails; +} + +export interface IContextDetails { + uniqueId: string; + createdAt: Date; + channelId: string; + chaincode: string; + parameters: string[]; + txId?: string; + creator: string; +} + +export interface IProcessDetails { + host: string; + uptime: string; + loadAvg: number[]; +} + +export interface IRequestDetails { + host: string; + path: string; + port: string; + headers: Record; +} + +export interface IResponseDetails { + isError: boolean; + statusCode: number; + message: string; + payload: Record; +} + +export interface ITimeLogData { + description: string; + requestId: string; + elapsed: string; + method: string; + info?: ILoggerCommons; + metaData?: unknown[]; +} diff --git a/chain-api/src/types/mint.ts b/chain-api/src/types/mint.ts new file mode 100644 index 000000000..cd916cfab --- /dev/null +++ b/chain-api/src/types/mint.ts @@ -0,0 +1,339 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { ArrayMaxSize, ArrayNotEmpty, IsNotEmpty, IsOptional, ValidateNested } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { BigNumberProperty } from "../utils"; +import { ArrayUniqueObjects, BigNumberIsNotNegative } from "../validators"; +import { TokenClassKey } from "./TokenClass"; +import { AllowanceKey, MintRequestDto } from "./common"; +import { ChainCallDTO } from "./dtos"; + +@JSONSchema({ + description: + "Describes an action to mint a token. " + + `For NFTs you can mint up to ${MintTokenDto.MAX_NFT_MINT_SIZE} tokens.` +}) +export class MintTokenDto extends ChainCallDTO { + static MAX_NFT_MINT_SIZE = 1000; + + @JSONSchema({ + description: "Token class of token to be minted." + }) + @ValidateNested() + @Type(() => TokenClassKey) + @IsNotEmpty() + tokenClass: TokenClassKey; + + @JSONSchema({ + description: "The owner of minted tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: "How many units of Fungible/NonFungible Token will be minted." + }) + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; + + @IsOptional() + @Type(() => AllowanceKey) + @IsNotEmpty() + public allowanceKey?: AllowanceKey; +} + +@JSONSchema({ + description: + "Describes an action to grant allowance to self and mint token to owner in single transaction. " + + "This action will fail is the calling user lacks the authority to grant MINT allowances." +}) +export class MintTokenWithAllowanceDto extends ChainCallDTO { + @JSONSchema({ + description: "Token class of token to be minted." + }) + @ValidateNested() + @Type(() => TokenClassKey) + @IsNotEmpty() + tokenClass: TokenClassKey; + + @JSONSchema({ + description: "The owner of minted tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: "Instance of token to be minted" + }) + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + tokenInstance: BigNumber; + + @JSONSchema({ + description: "How many units of Fungible/NonFungible Token will be minted." + }) + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; +} + +@JSONSchema({ + description: + "Describes an action to transferToken a token. " + + `For NFTs you can mint up to ${MintTokenDto.MAX_NFT_MINT_SIZE} tokens.` +}) +export class BatchMintTokenDto extends ChainCallDTO { + static MAX_ARR_SIZE = 1000; + + @JSONSchema({ + description: "DTOs of tokens to mint." + }) + @ValidateNested({ each: true }) + @Type(() => MintTokenDto) + @ArrayNotEmpty() + @ArrayMaxSize(BatchMintTokenDto.MAX_ARR_SIZE) + mintDtos: Array; +} + +/** + * Experimental: Defines an action to mint a token. High-throughput implementation. + * + * @experimental 2023-03-23 + */ +@JSONSchema({ + description: + "Experimental: Describes an action to mint a token. High-throughput implementation. " + + "DTO properties backwards-compatible with prior MintTokenDto," +}) +export class HighThroughputMintTokenDto extends ChainCallDTO { + // todo: remove all these duplicated properties + // it seems something about our @GalaTransaction decorator does not pass through + // parent properties. Leaving this class empty with just the `extends MintTokenDto` + // results in an api definition with no property except the signature. + // update: seems extending MintTokenDto results in failures value.toFixed is not a function, + // presumably something about the quantity and our dynamic type/class validator + static MAX_NFT_MINT_SIZE = 1000; + + @JSONSchema({ + description: "Token class of token to be minted." + }) + @ValidateNested() + @Type(() => TokenClassKey) + @IsNotEmpty() + tokenClass: TokenClassKey; + + @JSONSchema({ + description: "The owner of minted tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: "How many units of fungible token of how many NFTs are going to be minted." + }) + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; + + @IsOptional() + @Type(() => AllowanceKey) + @IsNotEmpty() + public allowanceKey?: AllowanceKey; +} + +@JSONSchema({ + description: + "Experimental: After submitting request to RequestMintAllowance, follow up with FulfillMintAllowance." +}) +export class FulfillMintDto extends ChainCallDTO { + static MAX_ARR_SIZE = 1000; + + @ValidateNested({ each: true }) + @Type(() => MintRequestDto) + @ArrayNotEmpty() + @ArrayMaxSize(FulfillMintDto.MAX_ARR_SIZE) + @ArrayUniqueObjects("id") + requests: MintRequestDto[]; +} + +@JSONSchema({ + description: "Fetch MintRequest or MintAllowanceRequest objects off chain." +}) +export class FetchMintRequestsDto extends ChainCallDTO { + @JSONSchema({ + description: "Token collection." + }) + @IsNotEmpty() + collection: string; + + @JSONSchema({ + description: "Token category." + }) + @IsNotEmpty() + category: string; + + @JSONSchema({ + description: "Token type." + }) + @IsNotEmpty() + type: string; + + @JSONSchema({ + description: "Token additionalKey." + }) + @IsNotEmpty() + additionalKey: string; + + @IsNotEmpty() + startTimestamp: number; + + @IsNotEmpty() + endTimestamp: number; +} + +@JSONSchema({ + description: "Fetch Mint, Burn or Mint Allowance supply totals off chain." +}) +export class FetchTokenSupplyDto extends ChainCallDTO { + @JSONSchema({ + description: "Token collection." + }) + @IsNotEmpty() + collection: string; + + @JSONSchema({ + description: "Token category." + }) + @IsNotEmpty() + category: string; + + @JSONSchema({ + description: "Token type." + }) + @IsNotEmpty() + type: string; + + @JSONSchema({ + description: "Token additionalKey." + }) + @IsNotEmpty() + additionalKey: string; +} + +@JSONSchema({ + description: "Fetch MintRequest or MintAllowanceRequest objects off chain and return the supply." +}) +export class FetchTokenSupplyResponse extends ChainCallDTO { + @JSONSchema({ + description: "Total known supply at time of chaincode execution." + }) + @BigNumberProperty() + supply: BigNumber; +} + +@JSONSchema({ + description: + "Write a MintAllowanceRequest object to chain. " + + "Designed to patch, update, or correct the known total supply. " + + "An administrative / token authority can patch in the chain objects " + + "needed with an off-chain, correctly-calculated total supply " + + "such that ongoing high throughput mints/mint allowances are migrated " + + "to a correct running total." +}) +export class PatchMintAllowanceRequestDto extends ChainCallDTO { + @JSONSchema({ + description: "Token collection." + }) + @IsNotEmpty() + collection: string; + + @JSONSchema({ + description: "Token category." + }) + @IsNotEmpty() + category: string; + + @JSONSchema({ + description: "Token type." + }) + @IsNotEmpty() + type: string; + + @JSONSchema({ + description: "Token additionalKey." + }) + @IsNotEmpty() + additionalKey: string; + + @JSONSchema({ + description: "The total known mint allowances count." + }) + @IsNotEmpty() + @BigNumberProperty() + totalKnownMintAllowancesCount: BigNumber; +} + +@JSONSchema({ + description: + "Write MintRequest objects to chain. " + + "Designed to patch, update, or correct the known total supply. " + + "An administrative / token authority can patch in the chain objects " + + "needed with an off-chain, correctly-calculated total supply " + + "such that ongoing high throughput mints/mint allowances are migrated " + + "to a correct running total." +}) +export class PatchMintRequestDto extends ChainCallDTO { + @JSONSchema({ + description: "Token collection." + }) + @IsNotEmpty() + collection: string; + + @JSONSchema({ + description: "Token category." + }) + @IsNotEmpty() + category: string; + + @JSONSchema({ + description: "Token type." + }) + @IsNotEmpty() + type: string; + + @JSONSchema({ + description: "Token additionalKey." + }) + @IsNotEmpty() + additionalKey: string; + + @JSONSchema({ + description: "The total known mint allowances count." + }) + @IsNotEmpty() + @BigNumberProperty() + totalKnownMintsCount: BigNumber; +} diff --git a/chain-api/src/types/token.ts b/chain-api/src/types/token.ts new file mode 100644 index 000000000..d2b74211e --- /dev/null +++ b/chain-api/src/types/token.ts @@ -0,0 +1,516 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsAlpha, + IsBoolean, + IsInt, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUrl, + Max, + MaxLength, + Min, + ValidateIf, + ValidateNested +} from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { BigNumberProperty } from "../utils"; +import { BigNumberIsNotNegative, BigNumberIsPositive } from "../validators"; +import { TokenBalance } from "./TokenBalance"; +import { TokenClass, TokenClassKey } from "./TokenClass"; +import { TokenInstance, TokenInstanceKey } from "./TokenInstance"; +import { ChainCallDTO } from "./dtos"; + +@JSONSchema({ + description: "Contains list of objects representing token classes to fetch." +}) +export class FetchTokenClassesDto extends ChainCallDTO { + @ValidateNested({ each: true }) + @Type(() => TokenClassKey) + @ArrayNotEmpty() + tokenClasses: Array; +} + +@JSONSchema({ + description: + "Fetch token classes currently available in world state. Supports filtering, " + + "pagination, and optionality of TokenClassKey properties." +}) +export class FetchTokenClassesWithPaginationDto extends ChainCallDTO { + static readonly MAX_LIMIT = 10 * 1000; + static readonly DEFAULT_LIMIT = 1000; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey. Optional, but required if instance is provided." + }) + @ValidateIf((o) => !!o.instance) + @IsNotEmpty() + additionalKey?: string; + + @JSONSchema({ + description: "Page bookmark. If it is undefined, then the first page is returned." + }) + @IsOptional() + @IsNotEmpty() + bookmark?: string; + + @JSONSchema({ + description: + `Page size limit. ` + + `Defaults to ${FetchTokenClassesWithPaginationDto.DEFAULT_LIMIT}, max possible value ${FetchTokenClassesWithPaginationDto.MAX_LIMIT}. ` + + "Note you will likely get less results than the limit, because the limit is applied before additional filtering." + }) + @IsOptional() + @Max(FetchTokenClassesWithPaginationDto.MAX_LIMIT) + @Min(1) + @IsInt() + limit?: number; +} + +export class FetchTokenClassesResponse extends ChainCallDTO { + @JSONSchema({ description: "List of Token Classes." }) + @ValidateNested({ each: true }) + @Type(() => TokenClass) + results: TokenClass[]; + + @JSONSchema({ description: "Next page bookmark." }) + @IsOptional() + @IsNotEmpty() + nextPageBookmark?: string; +} + +@JSONSchema({ + description: "Contains list of objects representing token instances to fetch." +}) +export class FetchTokenInstancesDto extends ChainCallDTO { + @ValidateNested({ each: true }) + @Type(() => TokenInstanceKey) + @ArrayNotEmpty() + tokenInstances: Array; +} + +@JSONSchema({ + description: + "Contains properties of token class to be created. Actual token units and NFT instances are created on mint." +}) +export class CreateTokenClassDto extends ChainCallDTO { + static DEFAULT_NETWORK = "GC"; + static DEFAULT_DECIMALS = 0; + static DEFAULT_MAX_CAPACITY = new BigNumber("Infinity"); + static DEFAULT_MAX_SUPPLY = new BigNumber("Infinity"); + static INITIAL_MINT_ALLOWANCE = new BigNumber("0"); + static INITIAL_TOTAL_SUPPLY = new BigNumber("0"); + static INITIAL_TOTAL_BURNED = new BigNumber("0"); + + @JSONSchema({ + description: + `A network of the token. An optional field, by default set to ${CreateTokenClassDto.DEFAULT_NETWORK}. ` + + `Custom value is required when we want to use different network than ${CreateTokenClassDto.DEFAULT_NETWORK} ` + + `to store tokens (but this is not supported yet).` + }) + @IsOptional() + @IsNotEmpty() + network?: string; + + @JSONSchema({ + description: `If missing, and for NFTs, it is set to ${CreateTokenClassDto.DEFAULT_DECIMALS}.` + }) + @Min(0) + @Max(32) + @IsOptional() + decimals?: number; + + @JSONSchema({ + description: `If missing, set to ${CreateTokenClassDto.DEFAULT_MAX_CAPACITY}.` + }) + @IsOptional() + @BigNumberIsPositive() + @BigNumberProperty({ allowInfinity: true }) + maxCapacity?: BigNumber; + + @JSONSchema({ + description: `If missing, set to infinity ${CreateTokenClassDto.DEFAULT_MAX_SUPPLY}.` + }) + @IsOptional() + @BigNumberIsPositive() + @BigNumberProperty({ allowInfinity: true }) + maxSupply?: BigNumber; + + @JSONSchema({ + description: "A unique identifier of this token." + }) + @ValidateNested() + @Type(() => TokenClassKey) + @IsNotEmpty() + tokenClass: TokenClassKey; + + @MaxLength(200) + name: string; + + @MaxLength(20) + @IsAlpha() + symbol: string; + + @IsNotEmpty() + @MaxLength(1000) + description: string; + + @JSONSchema({ + description: + `How much units or how many NFTs were allowed to be minted in the past. ` + + `By default set to ${CreateTokenClassDto.INITIAL_MINT_ALLOWANCE}.` + }) + @IsOptional() + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + totalMintAllowance?: BigNumber; + + @JSONSchema({ + description: + `How much units or how many NFTs are already on the market. ` + + `By default set to ${CreateTokenClassDto.INITIAL_TOTAL_SUPPLY}.` + }) + @IsOptional() + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + totalSupply?: BigNumber; + + @JSONSchema({ + description: + `How much units or how many NFTs ware already burned. ` + + `By default set to ${CreateTokenClassDto.INITIAL_TOTAL_BURNED}.` + }) + @IsOptional() + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + totalBurned?: BigNumber; + + @IsOptional() + @MaxLength(500) + contractAddress?: string; + + @IsOptional() + @MaxLength(500) + metadataAddress?: string; + + @JSONSchema({ + description: "How rare is the NFT" + }) + @IsOptional() + @IsAlpha() + rarity?: string; + + @IsUrl() + image: string; + + @JSONSchema({ + description: "Determines if the token is an NFT. Set to false if missing." + }) + @IsOptional() + @IsBoolean() + isNonFungible?: boolean; + + @JSONSchema({ + description: + "List of chain user identifiers who should become token authorities. " + + "Only token authorities can give mint allowances. " + + "By default the calling user becomes a single token authority. " + }) + @IsString({ each: true }) + @IsOptional() + @ArrayNotEmpty() + authorities?: string[]; +} + +export class UpdateTokenClassDto extends ChainCallDTO { + /* todo: should these fields be update-able? probably not, unless in exceptional circumstances. + these are more complicted, as they track properties with second order effects. + in theory, it's probably a bad idea if a token authority can just come in later + and up the total amount of what was meant to be a scarce NFT. + Also, implementation will be more complicated to ensure that chagnes like a + reducation in capacity don't end up with invalid values. + maxCapacity?: BigNumber; + maxSupply?: BigNumber; + totalSupply?: BigNumber; + totalBurned?: BigNumber; + isNonFungible?: boolean; + network?: string; + */ + + @JSONSchema({ + description: "The unique identifier of the existing token which will be updated." + }) + @ValidateNested() + @Type(() => TokenClassKey) + @IsNotEmpty() + tokenClass: TokenClassKey; + + @IsOptional() + @MaxLength(200) + name?: string; + + @IsOptional() + @MaxLength(20) + @IsAlpha() + symbol?: string; + + @IsOptional() + @IsNotEmpty() + @MaxLength(1000) + description?: string; + + @IsOptional() + @MaxLength(500) + contractAddress?: string; + + @IsOptional() + @MaxLength(500) + metadataAddress?: string; + + @JSONSchema({ + description: "How rare is the NFT" + }) + @IsOptional() + @IsAlpha() + rarity?: string; + + @IsOptional() + @IsUrl() + image?: string; + + @JSONSchema({ + description: + "List of chain user identifiers who should become token authorities. " + + "Only token authorities can give mint allowances. " + + "By default the calling user becomes a single token authority. " + }) + @IsString({ each: true }) + @IsOptional() + @ArrayNotEmpty() + authorities?: string[]; + + @JSONSchema({ + description: + "Overwrite existing authorities completely with new values. Default: false. " + + "The default behavior will augment the existing authorities with new values. " + + "Set this to true and provide a full list to remove one or more existing authorities." + }) + @IsOptional() + overwriteAuthorities?: boolean; +} + +@JSONSchema({ + description: "Contains parameters for fetching balances. Each parameter is optional." +}) +export class FetchBalancesDto extends ChainCallDTO { + @JSONSchema({ + description: "Person who owns the balance. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey." + }) + @IsOptional() + additionalKey?: string; +} + +@JSONSchema({ + description: "Contains parameters for fetching balances. Each parameter is optional." +}) +export class FetchBalancesWithPaginationDto extends ChainCallDTO { + static readonly MAX_LIMIT = 10 * 1000; + static readonly DEFAULT_LIMIT = 1000; + + @JSONSchema({ + description: "Person who owns the balance. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: "Token collection. Optional, but required if category is provided." + }) + @ValidateIf((o) => !!o.category) + @IsNotEmpty() + collection?: string; + + @JSONSchema({ + description: "Token category. Optional, but required if type is provided." + }) + @ValidateIf((o) => !!o.type) + @IsNotEmpty() + category?: string; + + @JSONSchema({ + description: "Token type. Optional, but required if additionalKey is provided." + }) + @ValidateIf((o) => !!o.additionalKey) + @IsNotEmpty() + type?: string; + + @JSONSchema({ + description: "Token additionalKey." + }) + @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 limit. ` + + `Defaults to ${FetchBalancesWithPaginationDto.DEFAULT_LIMIT}, max possible value ${FetchBalancesWithPaginationDto.MAX_LIMIT}. ` + + "Note you will likely get less results than the limit, because the limit is applied before additional filtering." + }) + @IsOptional() + @Max(FetchBalancesWithPaginationDto.MAX_LIMIT) + @Min(1) + @IsInt() + limit?: number; +} + +@JSONSchema({ + description: "Response DTO containing a TokenBalance and the balance's corresponding TokenClass." +}) +export class TokenBalanceWithMetadata extends ChainCallDTO { + @JSONSchema({ + description: "A TokenBalance read of chain." + }) + @ValidateNested() + @Type(() => TokenBalance) + @IsObject() + balance: TokenBalance; + + @JSONSchema({ + description: "The TokenClass metadata corresponding to the TokenBalance on this DTO." + }) + @Type(() => TokenClass) + @IsObject() + token: TokenClass; +} + +export class FetchBalancesWithTokenMetadataResponse extends ChainCallDTO { + @JSONSchema({ description: "List of balances with token metadata." }) + @ValidateNested({ each: true }) + @Type(() => TokenBalanceWithMetadata) + results: TokenBalanceWithMetadata[]; + + @JSONSchema({ description: "Next page bookmark." }) + @IsOptional() + @IsNotEmpty() + nextPageBookmark?: string; +} + +@JSONSchema({ + description: + "Experimental: After submitting request to RequestMintAllowance, follow up with FulfillMintAllowance." +}) +export class TransferTokenDto extends ChainCallDTO { + @JSONSchema({ + description: "The current owner of tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + from?: string; + + @IsNotEmpty() + to: string; + + @JSONSchema({ + description: + "Token instance of token to be transferred. In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE}.` + }) + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + tokenInstance: TokenInstanceKey; + + @JSONSchema({ + description: "The quantity of token units to be transferred." + }) + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; + + @JSONSchema({ + description: "Allowance ids to be used on transferToken (optional)." + }) + @IsString({ each: true }) + @IsOptional() + @ArrayNotEmpty() + useAllowances?: Array; +} diff --git a/chain-api/src/types/use.ts b/chain-api/src/types/use.ts new file mode 100644 index 000000000..ae469ce85 --- /dev/null +++ b/chain-api/src/types/use.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { ArrayNotEmpty, IsNotEmpty, IsOptional, IsString, ValidateNested } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { TokenInstance, TokenInstanceKey } from "../types/TokenInstance"; +import { ChainCallDTO } from "../types/dtos"; +import { BigNumberProperty } from "../utils"; +import { BigNumberIsNotNegative } from "../validators"; + +@JSONSchema({ + description: "Describes an action to release a token that is in use." +}) +export class ReleaseTokenDto extends ChainCallDTO { + @JSONSchema({ + description: "Token instance of token to be released." + }) + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + tokenInstance: TokenInstanceKey; +} + +@JSONSchema({ + description: "Describes an action to use a token." +}) +export class UseTokenDto extends ChainCallDTO { + @JSONSchema({ + description: "The current owner of tokens. If the value is missing, chaincode caller is used." + }) + @IsOptional() + @IsNotEmpty() + owner?: string; + + @JSONSchema({ + description: "The user who is going to use token." + }) + @IsNotEmpty() + inUseBy: string; + + @JSONSchema({ + description: + "Token instance of token to be used. In case of fungible tokens, tokenInstance.instance field " + + `should be set to ${TokenInstance.FUNGIBLE_TOKEN_INSTANCE}.` + }) + @ValidateNested() + @Type(() => TokenInstanceKey) + @IsNotEmpty() + tokenInstance: TokenInstanceKey; + + @JSONSchema({ + description: "The quantity of token units to be used." + }) + @IsNotEmpty() + @BigNumberIsNotNegative() + @BigNumberProperty() + quantity: BigNumber; + + @JSONSchema({ + description: "Allowance ids to be used (optional)." + }) + @IsString({ each: true }) + @IsOptional() + @ArrayNotEmpty() + useAllowances?: Array; +} diff --git a/chain-api/src/utils/chain-decorators.ts b/chain-api/src/utils/chain-decorators.ts new file mode 100644 index 000000000..f0c4c5f4a --- /dev/null +++ b/chain-api/src/utils/chain-decorators.ts @@ -0,0 +1,36 @@ +/* + * 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 "reflect-metadata"; + +export interface ChainKeyConfig { + position: number; +} + +export interface ChainKeyMetadata extends ChainKeyConfig { + key: string | symbol; +} + +export function ChainKey(chainKeyConfig: ChainKeyConfig): PropertyDecorator { + return (target, key) => { + const fields: ChainKeyMetadata[] = Reflect.getOwnMetadata("galachain:chainkey", target) || []; + + const existingField = fields.find((field) => field.position === chainKeyConfig.position); + + if (existingField === undefined) { + fields.push({ key, ...chainKeyConfig }); + Reflect.defineMetadata("galachain:chainkey", fields, target); + } + }; +} diff --git a/chain-api/src/utils/deserialize.ts b/chain-api/src/utils/deserialize.ts new file mode 100644 index 000000000..5a6bf0ed1 --- /dev/null +++ b/chain-api/src/utils/deserialize.ts @@ -0,0 +1,45 @@ +/* + * 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"; + +type Base = T extends BaseT ? T : never; + +// `any` is specified on purpose to avoid some compilation errors when `unknown` is provided here +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Inferred = T extends (infer U)[] ? Base : Base; + +export interface ClassConstructor { + new (...args: unknown[]): T; +} + +const opts = { + // do NOT use enableImplicitConversion, since the benefits are lost when the library is imported +}; + +export default function customDeserialize( + constructor: ClassConstructor>, + object: string | Record | Record[] +): T { + if (typeof object === "string") { + const parsed = JSON.parse(object); + if (Array.isArray(parsed)) { + return (parsed as BaseT[]).map((o) => plainToInstance(constructor, o, opts)) as unknown as T; + } else { + return plainToInstance(constructor, parsed, opts) as unknown as T; + } + } else { + return plainToInstance(constructor, object, opts) as unknown as T; + } +} diff --git a/chain-api/src/utils/error.spec.ts b/chain-api/src/utils/error.spec.ts new file mode 100644 index 000000000..666e9e12e --- /dev/null +++ b/chain-api/src/utils/error.spec.ts @@ -0,0 +1,92 @@ +/* + * 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 { ChainError, DefaultError, ErrorCode, ForbiddenError, NotFoundError } from "./error"; + +it("should create proper error keys", async () => { + // Given + class PkNotFoundError extends NotFoundError {} + class CannotParseX509Error extends ForbiddenError {} + class CAError extends ForbiddenError {} + + // When + const defaultError = new DefaultError("message1"); + const child1Error = new PkNotFoundError("message2", { a: 1 }); + const child2Error = new PkNotFoundError("message3", "CUSTOM-KEY-ERROR"); + const child3Error = new CannotParseX509Error("message4"); + const child4Error = new CAError("message5"); + + // Then + expect(defaultError.key).toEqual("DEFAULT"); + expect(child1Error.key).toEqual("PK_NOT_FOUND"); + expect(child2Error.key).toEqual("CUSTOM_KEY_ERROR"); + expect(child3Error.key).toEqual("CANNOT_PARSE_X_509"); + expect(child4Error.key).toEqual("CA"); +}); + +it("should match by class or error code", () => { + // Given + class PkNotFoundError extends NotFoundError {} + + // When + const e1 = new PkNotFoundError("bb"); + const e2 = { message: "aa", code: ErrorCode.FORBIDDEN }; + const e3 = new Error("aa"); + + // Then + expect(ChainError.matches(e1, ErrorCode.NOT_FOUND)).toEqual(true); + expect(ChainError.matches(e1, ErrorCode.FORBIDDEN)).toEqual(false); + expect(ChainError.matches(e1, PkNotFoundError)).toEqual(true); + expect(ChainError.matches(e1, NotFoundError)).toEqual(false); + expect(ChainError.matches(e2, ErrorCode.FORBIDDEN)).toEqual(false); + expect(ChainError.matches(e3, DefaultError)).toEqual(false); + expect(ChainError.matches(ChainError.from(e3), DefaultError)).toEqual(true); +}); + +it("should map to a different error", () => { + // Given + class PkNotFoundError extends NotFoundError {} + const error = new NotFoundError("abc not found", { key: "abc" }); + + // When + const mapped1 = error.map(NotFoundError, (e) => new PkNotFoundError(e.message, e.payload)); + const mapped2 = error.map(ErrorCode.NOT_FOUND, (e) => new PkNotFoundError(e.message, e.payload)); + const notMapped = error.map(DefaultError, (e) => new PkNotFoundError(e.message, e.payload)); + + // Then + expect(mapped1.message).toEqual(error.message); + expect(mapped1.payload).toEqual(error.payload); + expect(ChainError.matches(error, PkNotFoundError)).toEqual(false); + + expect(mapped2.message).toEqual(error.message); + expect(mapped2.payload).toEqual(error.payload); + expect(ChainError.matches(error, PkNotFoundError)).toEqual(false); + + expect(notMapped.message).toEqual(error.message); + expect(notMapped.payload).toEqual(error.payload); + expect(ChainError.matches(notMapped, PkNotFoundError)).toEqual(false); +}); + +it("should take error payload", () => { + // When + const error1 = new NotFoundError("abc not found", { key: "abc" }); + const error2 = new NotFoundError("abc not found", "ABC_NOT_FOUND", { key: "abc" }); + + // Then + expect(error1.payload).toEqual({ key: "abc" }); + expect(error1.key).toEqual("NOT_FOUND"); + + expect(error2.payload).toEqual({ key: "abc" }); + expect(error2.key).toEqual("ABC_NOT_FOUND"); +}); diff --git a/chain-api/src/utils/error.ts b/chain-api/src/utils/error.ts new file mode 100644 index 000000000..88dca72f6 --- /dev/null +++ b/chain-api/src/utils/error.ts @@ -0,0 +1,222 @@ +/* + * 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 ClassConstructor = { + new (...args: unknown[]): T; +}; + +export enum ErrorCode { + VALIDATION_FAILED = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + CONFLICT = 409, + NO_LONGER_AVAILABLE = 410, + DEFAULT_ERROR = 500, + NOT_IMPLEMENTED = 501 +} + +export interface OptionalChainErrorData { + message?: string; + code?: ErrorCode; + key?: Uppercase; + payload?: Record; +} + +export abstract class ChainError extends Error implements OptionalChainErrorData { + /** + * Status code, a value from ErrorCode enum. It is directly mapped to HTTP, + * status, it is a constant value to be used by clients integrating with + * the chain. + */ + public readonly code: ErrorCode; + + /** + * An upper case string to be used as a key do diagnose where the error comes + * from and help with regular development. It should not be used by client + * integrating with the chain since we don't guarantee it won't change. + * It is generated from original error class name. + */ + public readonly key: Uppercase; + + /** + * Additional information to be used by + */ + public readonly payload?: Record; + constructor(message: string); + constructor(message: string, key: Uppercase); + constructor(message: string, key: Uppercase, payload: Record); + constructor(message: string, payload: Record | unknown); + + constructor( + message: string, + payloadOrKey?: Record | Uppercase, + payloadOpt?: Record + ) { + super(message); + const [key, payload] = + typeof payloadOrKey === "string" + ? [payloadOrKey as Uppercase, payloadOpt] + : [undefined, payloadOrKey]; + + this.key = ChainError.normalizedKey(key ?? this.constructor); + this.payload = payload; + } + + // eslint-disable-next-line @typescript-eslint/ban-types + public static normalizedKey(fn: string | Function): Uppercase { + let rawCode: string; + + if (typeof fn === "string") { + rawCode = fn; + } else { + const regex = /[A-Z]{2,}(?=[A-Z]+[a-z]*[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g; + rawCode = + fn.name + .match(regex) + ?.join("_") + .toUpperCase() + .replace(/_ERROR$/g, "") ?? "UNKNOWN"; + } + + return rawCode.toUpperCase().replace(/[^A-Z0-9_]/g, "_") as Uppercase; + } + + public static withCode(code: ErrorCode) { + return class ChainErrorWithCode extends ChainError { + public readonly code = code; + } as ClassConstructor; + } + + /** + * Allows to execute function getting as a parameter the current error. + * + * @param fn + * + * @example + * throw CommonChainError.objectNotFound(objectId).andExec((e) => { + * logger.error(e.message); + * }); + */ + public andExec(fn: (e: ChainError) => void): ChainError { + fn(this); + return this; + } + + public logError(logger: { error(message: string): void }): ChainError { + logger.error(this.message); + return this; + } + + public logWarn(logger: { warn(message: string): void }): ChainError { + logger.warn(this.message); + return this; + } + + public matches(key: ErrorCode | ClassConstructor): boolean { + if (typeof key === "function") { + return !!key.name && this.constructor.name === key.name; + } else { + return key === this.code; + } + } + + /** + * Maps ChainError to another chain error by error code if `key` param matches + * current error code or current diagnostic key. Otherwise, returns original + * error. + * + * Useful in rethrowing an error or mapping an error to another one in catch + * clauses or catch methods in promises. + * + * @param key error code or error class to match + * @param newError new error or a function to create the new error + */ + public map( + key: ErrorCode | ClassConstructor, + newError: ChainError | ((e: ChainError) => ChainError) + ): ChainError { + // return original error if there is no key match + if (!this.matches(key)) { + return this; + } + + if (typeof newError === "function") { + return newError(this); + } + + return newError; + } + + public static isChainError(e: object | undefined): e is ChainError { + return !!e && "key" in e && e.key !== undefined && "code" in e && e.code !== undefined; + } + + public static from(e: object & { message?: string }): ChainError { + return this.isChainError(e) ? e : new DefaultError(e?.message ?? "Unknown error occured"); + } + + public static matches( + e: { message?: string } | ChainError, + key: ErrorCode | ClassConstructor + ): boolean { + return ChainError.isChainError(e) && e.matches(key); + } + + /** + * Maps ChainError to another chain error by error code, or returns original + * error if no error code matches, or returns default chain error if a given + * parameter is not a ChainError instance. + * + * Useful in rethrowing an error or mapping an error to another one in catch + * clauses or catch methods in promises. + * + * @param e original error + * @param key error code or error class to match + * @param newError new error or a function to create the new error + */ + public static map( + e: { message?: string } | ChainError, + key: ErrorCode | ClassConstructor, + newError: ChainError | ((e: ChainError) => ChainError) + ): ChainError { + if (ChainError.isChainError(e)) { + return e.map(key, newError); + } else { + return ChainError.from(e); + } + } +} + +export class ValidationFailedError extends ChainError.withCode(ErrorCode.VALIDATION_FAILED) {} + +export class UnauthorizedError extends ChainError.withCode(ErrorCode.UNAUTHORIZED) {} + +export class PaymentRequiredError extends ChainError.withCode(ErrorCode.PAYMENT_REQUIRED) {} + +export class ForbiddenError extends ChainError.withCode(ErrorCode.FORBIDDEN) {} + +export class NotFoundError extends ChainError.withCode(ErrorCode.NOT_FOUND) {} + +export class ConflictError extends ChainError.withCode(ErrorCode.CONFLICT) {} + +export class NoLongerAvailableError extends ChainError.withCode(ErrorCode.NO_LONGER_AVAILABLE) {} + +export class DefaultError extends ChainError.withCode(ErrorCode.DEFAULT_ERROR) {} + +export class RuntimeError extends ChainError.withCode(ErrorCode.DEFAULT_ERROR) {} + +export class NotImplementedError extends ChainError.withCode(ErrorCode.NOT_IMPLEMENTED) {} diff --git a/chain-api/src/utils/generate-schema.spec.ts b/chain-api/src/utils/generate-schema.spec.ts new file mode 100644 index 000000000..ec518380b --- /dev/null +++ b/chain-api/src/utils/generate-schema.spec.ts @@ -0,0 +1,176 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Type } from "class-transformer"; +import { ArrayNotEmpty, IsNotEmpty, IsOptional, ValidateNested } from "class-validator"; +import { JSONSchema } from "class-validator-jsonschema"; + +import { ChainCallDTO } from "../types"; +import { BigNumberIsPositive } from "../validators"; +import { generateResponseSchema, generateSchema } from "./generate-schema"; +import { BigNumberProperty, EnumProperty } from "./transform-decorators"; + +enum YesNoEnum { + Yes, + No +} + +class TestCategory { + @IsNotEmpty() + name: string; +} + +@JSONSchema({ description: "Nested test class." }) +export class NestedTestClass { + @IsNotEmpty() + public collection: string; + + @JSONSchema({ description: "Test category" }) + @ValidateNested({ each: true }) + @Type(() => TestCategory) + public categories: TestCategory[]; +} + +@JSONSchema({ description: "Some test DTO class" }) +class TestDto extends ChainCallDTO { + @JSONSchema({ description: "First part of the description." }) + @ValidateNested() + @Type(() => NestedTestClass) + nestedClass: NestedTestClass; + + @JSONSchema({ description: "Quantity used in some place to support some feature." }) + @BigNumberProperty() + @BigNumberIsPositive() + @IsOptional() + quantity?: BigNumber; + + @Type(() => BigNumber) + @ValidateNested({ each: true }) + @ArrayNotEmpty() + quantities: BigNumber[]; + + @EnumProperty(YesNoEnum) + amITestClass: YesNoEnum; +} + +const expectedNestedTestClassSchema = { + properties: { + collection: { minLength: 1, type: "string" }, + categories: { + items: { + properties: { name: { minLength: 1, type: "string" } }, + type: "object", + required: ["name"] + }, + type: "array", + description: "Test category" + } + }, + type: "object", + required: ["collection", "categories"], + description: "Nested test class." +}; + +const expectedTestDtoSchema = { + description: "Some test DTO class", + properties: { + nestedClass: { + ...expectedNestedTestClassSchema, + description: `First part of the description. ${expectedNestedTestClassSchema.description}` + }, + quantity: { + description: "Quantity used in some place to support some feature. Number provided as a string.", + type: "string" + }, + 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.\n" + + "JSON payload to be signed is created from an object without 'signature' and 'trace` properties, " + + "and it's keys should be sorted alphabetically and no end of line at the end. " + + 'Sample jq command to produce valid data to sign: `jq -cSj "." dto-file.json`.' + + "Also all BigNumber data should be provided as strings (not numbers) with fixed decimal point notation.\n" + + "The EC secp256k1 signature should be created for keccak256 hash of the data. " + + "The recommended format of the signature is a HEX encoded string, including r + s + v values. " + + "Signature in this format is supported by ethers.js library. " + + "Sample signature: b7244d62671319583ea8f30c8ef3b343cf28e7b7bd56e32b21a5920752dc95b94a9d202b2919581bcf776f0637462cb67170828ddbcc1ea63505f6a211f9ac5b1b\n" + + "This field can also contain a DER encoded signature, but this is deprecated and supported only to provide backwards compatibility. " + + "DER encoded signature cannot be used recover user's public key from the signature, " + + "and cannot be used with the new signature-based authorization flow for Gala Chain.\n", + minLength: 1, + type: "string" + }, + signerPublicKey: { + description: + "Public key of the user who signed the DTO. Required for DER encoded signatures, since they miss recovery part.", + minLength: 1, + type: "string" + }, + 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.\n" + + "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" + }, + quantities: { + items: { + description: "Number provided as a string.", + type: "string" + }, + minItems: 1, + type: "array" + }, + amITestClass: { + description: "0 - Yes, 1 - No", + enum: [0, 1], + type: "number" + } + }, + type: "object", + required: ["nestedClass", "quantities", "amITestClass"] +}; + +const expectedTestDtoResponseSchema = { + properties: { + Data: expectedTestDtoSchema, + Message: { + type: "string" + }, + Status: { + description: "Indicates Error (0) or Success (1)", + enum: [0, 1] + } + }, + required: ["Status"], + type: "object" +}; + +it("should generateSchema of NestedTestClass", async () => { + expect(generateSchema(NestedTestClass)).toEqual(expectedNestedTestClassSchema); +}); + +it("should generateSchema of TestDto", async () => { + expect(generateSchema(TestDto)).toEqual(expectedTestDtoSchema); +}); + +it("should generateResponseSchema of TestDto", async () => { + expect(generateResponseSchema(TestDto)).toEqual(expectedTestDtoResponseSchema); +}); diff --git a/chain-api/src/utils/generate-schema.ts b/chain-api/src/utils/generate-schema.ts new file mode 100644 index 000000000..2607fe705 --- /dev/null +++ b/chain-api/src/utils/generate-schema.ts @@ -0,0 +1,150 @@ +/* + * 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 { defaultMetadataStorage } from "class-transformer/cjs/storage"; +import { targetConstructorToSchema } from "class-validator-jsonschema"; +import { SchemaObject } from "openapi3-ts"; + +import { RuntimeError } from "./error"; + +type ClassConstructor = { + new (...args: unknown[]): unknown; +}; + +class GenerateSchemaError extends RuntimeError {} + +function customTargetConstructorToSchema(classType: ClassConstructor) { + const schema = targetConstructorToSchema(classType, { + additionalConverters: { + ["enumProperty"]: (meta) => { + const [values, , enumValuesInfo] = meta.constraints; // this is by convention in this decorator + const type: { type?: "number" } = values.every((v) => typeof v === "number") + ? { type: "number" } + : {}; + const schemaObj: SchemaObject = { + ...type, + enum: values, + description: enumValuesInfo + }; + return schemaObj; + } + }, + classTransformerMetadataStorage: defaultMetadataStorage + }); + + return schema; +} + +export type Primitive = "number" | "string" | "boolean" | "null" | "object"; + +function isPrimitive(x: unknown): x is Primitive { + return x === "number" || x === "string" || x === "boolean" || x === "null" || x === "object"; +} + +function isPrimitiveOrUndef(x: unknown): x is Primitive | undefined { + return isPrimitive(x) || x === undefined; +} + +function updateDefinitions( + object: SchemaObject, + rootClass: ClassConstructor, + property: string | undefined +): void { + // Replace BigNumber with string + if (object["$ref"] === "#/definitions/BigNumber") { + delete object["$ref"]; + object.type = "string"; + object.description = ((object.description ?? "") + " Number provided as a string.").trim(); + return; + } + + // Update items type in Arrays + if (object["type"] === "array") { + if (object.items && typeof object.items === "object") { + updateDefinitions(object.items as Record, rootClass, property); + } + return; + } + + // Try to get type from registry and expand it + if (object["$ref"] && typeof object["$ref"] === "string" && object["$ref"].startsWith("#/definitions/")) { + try { + const typeMetadata = defaultMetadataStorage.findTypeMetadata(rootClass, property); + + if (typeMetadata === undefined) { + const className = object["$ref"].replace("#/definitions/", ""); + throw new GenerateSchemaError(`Cannot find type metadata of ${className} for property ${property}`); + } + + if (typeof typeMetadata.typeFunction !== "function") { + throw new GenerateSchemaError(`reflectedType of ${typeMetadata} is not a function`); + } + + const classType = typeMetadata.typeFunction(); + const schema = customTargetConstructorToSchema(classType); + updateDefinitions(schema, classType, undefined); + + Object.keys(schema).map((k) => { + if (k === "description") { + // descriptions are appended, not overwritten + object[k] = `${object[k] ?? ""} ${schema[k] ?? ""}`.trim(); + } else { + object[k] = schema[k]; + } + }); + } finally { + object.type = "object"; + delete object["$ref"]; + } + return; + } + + Object.entries(object).forEach(([property, v]) => { + if (typeof v === "object" && !!v) { + updateDefinitions(v as Record, rootClass, property); + } + }); +} + +export function generateSchema(classType: ClassConstructor) { + const schema = customTargetConstructorToSchema(classType); + updateDefinitions(schema, classType, undefined); + return schema; +} + +export function generateResponseSchema( + type: ClassConstructor | Primitive | undefined, + isArray?: "array" +): SchemaObject { + const objectSchema: SchemaObject = isPrimitiveOrUndef(type) + ? { type: type ?? "null" } + : generateSchema(type); + const responseDataSchema: SchemaObject = + isArray === "array" ? { type: "array", items: objectSchema } : objectSchema; + + return { + type: "object", + properties: { + Status: { + enum: [0, 1], + description: "Indicates Error (0) or Success (1)" + }, + Message: { + type: "string" + }, + Data: responseDataSchema + }, + required: ["Status"] + }; +} diff --git a/chain-api/src/utils/getValidationErrorMessage.ts b/chain-api/src/utils/getValidationErrorMessage.ts new file mode 100644 index 000000000..02921c4a0 --- /dev/null +++ b/chain-api/src/utils/getValidationErrorMessage.ts @@ -0,0 +1,36 @@ +/* + * 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 { ValidationError } from "class-validator"; + +export interface ValidationErrorInfo { + message: string; + details: string[]; +} + +export function getValidationErrorInfo(validationErrors: ValidationError[]): ValidationErrorInfo { + const detailsArray = validationErrors.map((e) => { + const targetInfo = typeof e.target === "object" ? ` of ${e.target.constructor?.name ?? e.target}` : ""; + const intro = `Property '${e.property}'${targetInfo} has failed the following constraints:`; + const constraints = e.constraints ?? {}; + const constraintsKeys = Object.keys(constraints).sort(); + const details = constraintsKeys.map((k) => `${k}: ${constraints[k]}`); + return { message: `${intro} ${constraintsKeys.join(", ")}`, details }; + }); + + return { + message: detailsArray.map((d) => d.message).join(". "), + details: detailsArray.reduce((all, d) => [...all, ...d.details], []) + }; +} diff --git a/chain-api/src/utils/index.ts b/chain-api/src/utils/index.ts new file mode 100644 index 000000000..575c7d62e --- /dev/null +++ b/chain-api/src/utils/index.ts @@ -0,0 +1,35 @@ +import deserialize from "./deserialize"; +import { Primitive, generateResponseSchema, generateSchema } from "./generate-schema"; +import { ValidationErrorInfo, getValidationErrorInfo } from "./getValidationErrorMessage"; +import serialize from "./serialize"; +import signatures from "./signatures"; + +/* + * 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. + */ + +export * from "./chain-decorators"; +export * from "./error"; +export * from "./transform-decorators"; + +export { + deserialize, + serialize, + generateSchema, + generateResponseSchema, + getValidationErrorInfo, + ValidationErrorInfo, + Primitive, + signatures +}; diff --git a/chain-api/src/utils/serialize.spec.ts b/chain-api/src/utils/serialize.spec.ts new file mode 100644 index 000000000..fee3d3210 --- /dev/null +++ b/chain-api/src/utils/serialize.spec.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { ArrayMinSize, IsString } from "class-validator"; + +import { ChainCallDTO, ChainObject } from "../types"; +import serialize from "./serialize"; +import { BigNumberProperty } from "./transform-decorators"; + +class TestDTO extends ChainCallDTO { + @BigNumberProperty() + quantity: BigNumber; + + @IsString({ each: true }) + @ArrayMinSize(1) + names: string[]; +} + +class TestChainObject extends ChainObject { + @BigNumberProperty() + quantity: BigNumber; + + @IsString({ each: true }) + @ArrayMinSize(1) + names: string[]; +} + +function createTestObjects(plain: { quantity: BigNumber; names: string[] }) { + const dto = new TestDTO(); + dto.quantity = plain.quantity; + dto.names = plain.names; + + const chainObject = new TestChainObject(); + chainObject.quantity = plain.quantity; + chainObject.names = plain.names; + + return { dto, chainObject, plain }; +} + +it("should sort fields", () => { + // Given + const obj = { c: 8, b: [{ z: 6, y: 5, x: 4 }, 7], a: 3 }; + + // When + const serialized = serialize(obj); + + // Then + expect(serialized).toEqual('{"a":3,"b":[{"x":4,"y":5,"z":6},7],"c":8}'); +}); + +// Known issue, we used `sort-keys-recursive` before, which does sort arrays +it("should not sort arrays", () => { + // Given + const { dto, chainObject, plain } = createTestObjects({ + quantity: new BigNumber("1.23"), + names: ["Bob", "Alice", "Chris"] + }); + const expectedClassS = '{"names":["Bob","Alice","Chris"],"quantity":"1.23"}'; + const expectedPlainS = '{"names":["Bob","Alice","Chris"],"quantity":{"c":[1,23000000000000],"e":0,"s":1}}'; + + // When + const [plainS, dtoS, chainObjectS] = [plain, dto, chainObject].map((o) => serialize(o)); + + // Then + expect(dtoS).toEqual(expectedClassS); + expect(chainObjectS).toEqual(expectedClassS); + expect(plainS).toEqual(expectedPlainS); +}); + +it("should handle very large numbers with decimals", () => { + // Given + const largeNumberS = "12300000000000000000000000000000.456"; // Note no exp notation + const largeNumber = new BigNumber(largeNumberS); + expect(largeNumber.isGreaterThan(Number.MAX_SAFE_INTEGER)).toEqual(true); + + const { dto, chainObject, plain } = createTestObjects({ + quantity: largeNumber, + names: ["Alice"] + }); + const expectedClassS = `{"names":["Alice"],"quantity":"${largeNumberS}"}`; + const expectedPlainS = '{"names":["Alice"],"quantity":{"c":[1230,0,0,45600000000000],"e":31,"s":1}}'; + + // When + const [plainS, dtoS, chainObjectS] = [plain, dto, chainObject].map((o) => serialize(o)); + + // Then + expect(dtoS).toEqual(expectedClassS); + expect(chainObjectS).toEqual(expectedClassS); + expect(plainS).toEqual(expectedPlainS); +}); + +it("should handle very large numbers with no decimals", () => { + // Given + const largeNumberS = "900000000000000000000000000000"; // Note no exp notation + const largeNumber = new BigNumber(largeNumberS); + expect(largeNumber.isGreaterThan(Number.MAX_SAFE_INTEGER)).toEqual(true); + + const { dto, chainObject, plain } = createTestObjects({ + quantity: largeNumber, + names: ["Alice"] + }); + const expectedClassS = `{"names":["Alice"],"quantity":"${largeNumberS}"}`; + const expectedPlainS = '{"names":["Alice"],"quantity":{"c":[90],"e":29,"s":1}}'; + + // When + const [plainS, dtoS, chainObjectS] = [plain, dto, chainObject].map((o) => serialize(o)); + + // Then + expect(dtoS).toEqual(expectedClassS); + expect(chainObjectS).toEqual(expectedClassS); + expect(plainS).toEqual(expectedPlainS); +}); diff --git a/chain-api/src/utils/serialize.ts b/chain-api/src/utils/serialize.ts new file mode 100644 index 000000000..c74b701cd --- /dev/null +++ b/chain-api/src/utils/serialize.ts @@ -0,0 +1,20 @@ +/* + * 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 { classToPlain as instanceToPlain } from "class-transformer"; +import stringify from "json-stringify-deterministic"; + +export default function serialize(object: unknown) { + return stringify(instanceToPlain(object)); +} diff --git a/chain-api/src/utils/signatures.spec.ts b/chain-api/src/utils/signatures.spec.ts new file mode 100644 index 000000000..5100781ce --- /dev/null +++ b/chain-api/src/utils/signatures.spec.ts @@ -0,0 +1,295 @@ +/* + * 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 BN from "bn.js"; +import { ec as EC } from "elliptic"; + +import signatures from "./signatures"; + +describe("getPayloadToSign", () => { + it("should sort keys", () => { + // Given + const obj = { c: 8, b: [{ z: 6, y: 5, x: 4 }, 7], a: 3 }; + + // When + const toSign = signatures.getPayloadToSign(obj); + + // Then + expect(toSign).toEqual('{"a":3,"b":[{"x":4,"y":5,"z":6},7],"c":8}'); + }); + + it("should ignore 'signature' and 'trace' fields", () => { + // Given + const obj = { c: 8, signature: "to-be-ignored", trace: 3 }; + + // When + const toSign = signatures.getPayloadToSign(obj); + + // Then + expect(toSign).toEqual('{"c":8}'); + }); +}); + +describe("ethAddress", () => { + it("should calculate eth address from public key", () => { + // Given + const key = + "04a6eea22483bc0c512f3d3ce3ed9448aeba66ea353f0a832e3dd5d17fac38ee8149ae6e13ef5b2e37b1ed5fd23f7b23e07ecad9b70f9f01a0d852f702ff6f668a"; + const expectedAddr = "Af76AE5df7E92903043Aac92c8Af43C9806c44f2"; + + // When + const addr = signatures.getEthAddress(key); + + // Then + expect(addr).toEqual(expectedAddr); + }); +}); + +describe("public key", () => { + // https://privatekeys.pw/key/313871326028141e0bdeef59fe32a6fc51bce449e44907c191558cb6fdca1341#public + const nonCompact = + "04651b1e822f794444fbc96424da6b3536e725c92dbe0047f357cec15fbe5ff148ef0d0d37affaf4ee1d6d0da680bdbd177240913353c6792a60be6ddfb1ce25fb"; + const compact = "03651b1e822f794444fbc96424da6b3536e725c92dbe0047f357cec15fbe5ff148"; + const normalized = "A2UbHoIveURE+8lkJNprNTbnJcktvgBH81fOwV++X/FI"; + + // Legacy - on prod we keep all public keys as compact base64 + it("should normalize public key", () => { + // When + const normalizedKey = signatures.normalizePublicKey(nonCompact).toString("base64"); + + // Then + expect(normalizedKey).toEqual(normalized); + }); + + it("should get public key from hex compact", () => { + // When + const normalizedKey = signatures.getCompactBase64PublicKey(compact); + + // Then + expect(normalizedKey).toEqual(normalized); + }); + + it("should get public key from base64 compact", () => { + // When + const normalizedKey = signatures.getCompactBase64PublicKey(normalized); + + // Then + expect(normalizedKey).toEqual(normalized); + }); +}); + +describe("signatures", () => { + // see: https://privatekeys.pw/key/3b19099e96dccf44e1dfc13c89c7e490d902a96b0791faf185e194ae0e71786d + const privateKey = "3b19099e96dccf44e1dfc13c89c7e490d902a96b0791faf185e194ae0e71786d"; + const publicKey = + "04fa7d9e30902207fd821a1518ce777e1935a45e52180d6a6339f37c3e3f759d1a64e33ed1e334070d37731f6ce3f4a5daa6ee4c9884f21860601fed892d40b2a9"; + const publicKeyCompact = "03fa7d9e30902207fd821a1518ce777e1935a45e52180d6a6339f37c3e3f759d1a"; + const ethAddress = "28C6eB52a018CD3fc34dfbcBFF06Af1f9952Ab6E"; + + const payload = { c: 8, b: [{ z: 6, y: 5, x: 4 }, 7], a: 3 }; + const keccak = "79dd0efca18dd25aecc5aa5b5c6e89c1ea0ba96a04280cb7cb9a617e771d702b"; + + const signature = + "b7244d62671319583ea8f30c8ef3b343cf28e7b7bd56e32b21a5920752dc95b94a9d202b2919581bcf776f0637462cb67170828ddbcc1ea63505f6a211f9ac5b1b"; + const derSignature = + "3045022100b7244d62671319583ea8f30c8ef3b343cf28e7b7bd56e32b21a5920752dc95b902204a9d202b2919581bcf776f0637462cb67170828ddbcc1ea63505f6a211f9ac5b"; + + it("should explain the origin of pre-defined constants", () => { + const pkObj = new EC("secp256k1").keyFromPrivate(privateKey, "hex"); + expect(pkObj.getPublic().encode("hex", false).toString()).toEqual(publicKey); + expect(pkObj.getPublic().encode("hex", true).toString()).toEqual(publicKeyCompact); + + const pkKeccak = signatures.calculateKeccak256(Buffer.from(publicKey.slice(2), "hex")); + expect(pkKeccak.toString("hex")).toEqual( + "908004370d2800147b38cf7628c6eb52a018cd3fc34dfbcbff06af1f9952ab6e" + ); + expect(pkKeccak.slice(-20).toString("hex")).toEqual(ethAddress.toLowerCase()); + + const payloadBuffer = Buffer.from(signatures.getPayloadToSign(payload)); + expect(signatures.calculateKeccak256(payloadBuffer).toString("hex")).toEqual(keccak); + }); + + it("should get signature", async () => { + // Given + const privateKeyBuffer = Buffer.from(privateKey, "hex"); + + // When + const actualSignature = signatures.getSignature(payload, privateKeyBuffer); + + // Then + expect(actualSignature).toEqual(signature); + }); + + it("should get DER signature", async () => { + // Given + const privateKeyBuffer = Buffer.from(privateKey, "hex"); + + // When + const actualSignature = signatures.getDERSignature(payload, privateKeyBuffer); + + // Then + expect(actualSignature).toEqual(derSignature); + }); + + it("should normalize signature", async () => { + // When + const normalized = signatures.normalizeSecp256k1Signature(signature); + + // Then + expect(normalized).toEqual({ + r: new BN("b7244d62671319583ea8f30c8ef3b343cf28e7b7bd56e32b21a5920752dc95b9", "hex"), + s: new BN("4a9d202b2919581bcf776f0637462cb67170828ddbcc1ea63505f6a211f9ac5b", "hex"), + recoveryParam: 0 + }); + }); + + it("should normalize DER signature", async () => { + // When + const normalized = signatures.normalizeSecp256k1Signature(derSignature); + + // Then + expect(normalized).toEqual({ + r: new BN("b7244d62671319583ea8f30c8ef3b343cf28e7b7bd56e32b21a5920752dc95b9", "hex"), + s: new BN("4a9d202b2919581bcf776f0637462cb67170828ddbcc1ea63505f6a211f9ac5b", "hex"), + recoveryParam: undefined + }); + }); + + it("should verify signature", async () => { + // When + const actual = signatures.isValid(signature, payload, publicKey); + + // Then + expect(actual).toEqual(true); + }); + + it("should fail to verify signature (invalid payload)", async () => { + // Given + const invalidPayload = { ...payload, c: 9 }; + + // When + const actual = signatures.isValid(signature, invalidPayload, publicKey); + + // Then + expect(actual).toEqual(false); + }); + + it("should fail to verify signature (invalid signature)", async () => { + // Given + const invalidSignature = "aa" + signature.substring(2, signature.length); + + // When + const actual = signatures.isValid(invalidSignature, payload, publicKey); + + // Then + expect(actual).toEqual(false); + }); + + it("should fail to verify signature (invalid public key)", async () => { + // Given + const invalidPublicKey = publicKey.substring(0, publicKey.length - 2) + "00"; + + // When + const actual = signatures.isValid(signature, payload, invalidPublicKey); + + // Then + expect(actual).toEqual(false); + }); + + it("should verify signature with compact public key", async () => { + // When + const actual = signatures.isValid(signature, payload, publicKeyCompact); + + // Then + expect(actual).toEqual(true); + }); + + it("should verify DER signature", async () => { + // When + const actual = signatures.isValid(derSignature, payload, publicKey); + + // Then + expect(actual).toEqual(true); + }); + + it("should recover public key from signature", async () => { + // When + const actual = signatures.recoverPublicKey(signature, payload); + + // Then + expect(actual).toEqual(publicKey); + }); + + it("should fail to recover public key from DER signature", async () => { + // When + const actual = new Promise((res) => res(signatures.recoverPublicKey(derSignature, payload))); + + // Then + await expect(actual).rejects.toThrowError(/Signature must contain recovery part/); + }); + + it("Test multiple formats for each der signature length", async () => { + // when + const derSignatures = [ + // der138Signature + "3043022030ef238065ff606bcb59ce9b40923bb1f86777056eabbf77ecbefd101d207fbd021f14d3aed3bf7e07cb3bf2ef2c06cfde6db461eea8f58827df5b0fa4185d6535", + // der140signature + "304402205139bc0c17ffef0056b4f22c4f0feb577e02d484d03087d5daba8551d30c99d702207700e118778e264c975731606bc9aecd440ae8e0a4a7b1b3bd51820e8cc0da29", + // der142Signature + "3045022100b7244d62671319583ea8f30c8ef3b343cf28e7b7bd56e32b21a5920752dc95b902204a9d202b2919581bcf776f0637462cb67170828ddbcc1ea63505f6a211f9ac5b", + // der144Signature + "3045022100f6d3aefb132b74e879a937dc6a28003d6c2acee6e27291bfb24834aa2cd2a610022003a06b8f150ab71b3e19621114f98416417bea4fbc5b3b4a30b610fea9f281e1" + ]; + + const testMultipleFormats = (dersignature: string) => { + // normalize der signature to generate standard format + const normalizedSig = signatures.normalizeSecp256k1Signature(dersignature); + delete normalizedSig.recoveryParam; + const { r, s } = normalizedSig; + const standardHex = r.toString("hex", 32) + s.toString("hex", 32) + "1c"; + + const sigDict = { + // der format + dersignature, + base64Der: Buffer.from(dersignature, "hex").toString("base64"), + prefixDer: `0x${dersignature}`, + // standard format + standardHex, + base64StandardHex: Buffer.from(standardHex, "hex").toString("base64"), + prefixHex: `0x${standardHex}` + }; + + for (const [, value] of Object.entries(sigDict)) { + const derivedSig = signatures.normalizeSecp256k1Signature(value); + delete derivedSig.recoveryParam; + expect(derivedSig).toEqual(normalizedSig); + } + }; + + for (const val of derSignatures) { + testMultipleFormats(val); + } + + // test old failure case: + const shortSig = signatures.normalizeSecp256k1Signature( + "MEMCIDDvI4Bl/2Bry1nOm0CSO7H4Z3cFbqu/d+y+/RAdIH+9Ah8U067Tv34Hyzvy7ywGz95ttGHuqPWIJ99bD6QYXWU1" + ); + + expect(shortSig).toEqual({ + r: new BN("30ef238065ff606bcb59ce9b40923bb1f86777056eabbf77ecbefd101d207fbd", "hex"), + recoveryParam: undefined, + s: new BN("14d3aed3bf7e07cb3bf2ef2c06cfde6db461eea8f58827df5b0fa4185d6535", "hex") + }); + }); +}); diff --git a/chain-api/src/utils/signatures.ts b/chain-api/src/utils/signatures.ts new file mode 100644 index 000000000..21fff1666 --- /dev/null +++ b/chain-api/src/utils/signatures.ts @@ -0,0 +1,368 @@ +/* + * 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 BN from "bn.js"; +import { classToPlain as instanceToPlain } from "class-transformer"; +import { ec as EC, ec } from "elliptic"; +import Signature from "elliptic/lib/elliptic/ec/signature"; +import { keccak256 } from "js-sha3"; + +import { ValidationFailedError } from "./error"; +import serialize from "./serialize"; + +class InvalidKeyError extends ValidationFailedError {} + +class InvalidSignatureFormatError extends ValidationFailedError {} + +class InvalidDataHashError extends ValidationFailedError {} + +function getPayloadToSign(obj: object): string { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { signature, trace, ...plain } = instanceToPlain(obj); + return serialize(plain); +} + +const secpPrivKeyLength = { + secpBase64: 44, + secpHex1: 62, + secpHex2: 64, + secpHex3: 66, + + isHex: (length: number) => length >= secpPrivKeyLength.secpHex1 - 1 && length <= secpPrivKeyLength.secpHex3, + + isBase64: (length: number) => length === secpPrivKeyLength.secpBase64, + + isMissingTrailing0: (length: number) => + length === secpPrivKeyLength.secpHex1 - 1 || + length === secpPrivKeyLength.secpHex2 - 1 || + length === secpPrivKeyLength.secpHex3 - 1 +}; + +function normalizePrivateKey(input: string): Buffer { + const startsWith0x = input.startsWith("0x"); + const inputNo0x = startsWith0x ? input.slice(2) : input; + const length = inputNo0x.length; + + const encoding = secpPrivKeyLength.isHex(length) + ? "hex" + : secpPrivKeyLength.isBase64(length) + ? "base64" + : undefined; + + if (encoding !== undefined) { + const missing0 = secpPrivKeyLength.isMissingTrailing0(length) ? "0" : ""; + return Buffer.from(missing0 + inputNo0x, encoding); + } else { + const excl0x = startsWith0x ? " (excluding trailing '0x')" : ""; + const errorMessage = + `Cannot normalize secp256k1 private key. Got string of length ${length}, ` + + `but expected ${secpPrivKeyLength.secpBase64} for base46 encoding, ` + + `or ${secpPrivKeyLength.secpHex1}, ${secpPrivKeyLength.secpHex2} ` + + `or ${secpPrivKeyLength.secpHex3} for hex encoding${excl0x}.`; + throw new InvalidKeyError(errorMessage); + } +} + +const secpPubKeyLength = { + secpBase64Compressed: 44, + secpBase64: 88, + secpHexCompressed: 66, + secpHex: 130, + + isHex: (length: number) => + length === secpPubKeyLength.secpHex || length === secpPubKeyLength.secpHexCompressed, + + isBase64: (length: number) => + length === secpPubKeyLength.secpBase64 || length === secpPubKeyLength.secpBase64Compressed +}; + +function normalizePublicKey(input: string): Buffer { + const startsWith0x = input.startsWith("0x"); + const length = startsWith0x ? input.length - 2 : input.length; + const encoding = secpPubKeyLength.isHex(length) + ? "hex" + : secpPubKeyLength.isBase64(length) + ? "base64" + : undefined; + if (encoding !== undefined) { + const buffer = Buffer.from(startsWith0x ? input.slice(2) : input, encoding); + const pair = validateSecp256k1PublicKey(buffer); + return Buffer.from(pair.getPublic().encode("array", true)); + } else { + const excl0x = startsWith0x ? " (excluding trailing '0x')" : ""; + const errorMessage = + `Cannot normalize secp256k1 public key. Got string of length ${length}, ` + + `but expected ${secpPubKeyLength.secpBase64Compressed} or ${secpPubKeyLength.secpBase64} for base64, ` + + `or ${secpPubKeyLength.secpHexCompressed} or ${secpPubKeyLength.secpHex} for hex encoding${excl0x}.`; + throw new InvalidKeyError(errorMessage); + } +} + +function getCompactBase64PublicKey(publicKey: string): string { + return normalizePublicKey(publicKey).toString("base64"); +} + +function getNonCompactHexPublicKey(publicKey: string): string { + const normalized = normalizePublicKey(publicKey); + const pair = validateSecp256k1PublicKey(normalized); + return pair.getPublic().encode("hex", false); +} + +const ecSecp256k1 = new EC("secp256k1"); + +function getPublicKey(privateKey: string): string { + const pkObj = new EC("secp256k1").keyFromPrivate(privateKey, "hex"); + return pkObj.getPublic().encode("hex", false).toString(); +} + +function getEthAddress(publicKey: string) { + if (publicKey.length !== 130) { + const message = + `Invalid secp256k1 public key length: ${publicKey.length}. ` + + `Expected 130 characters (hex-encoded non-compact key).`; + throw new InvalidKeyError(message, { publicKey }); + } + + const publicKeyBuffer = Buffer.from(publicKey, "hex"); + const keccak = keccak256.digest(publicKeyBuffer.slice(1)); // skip "04" prefix + const addressLowerCased = Buffer.from(keccak.slice(-20)).toString("hex"); + return checksumedEthAddress(addressLowerCased); +} + +// the function below to calculate checksumed address is adapted from ethers.js +// see: https://github.com/ethers-io/ethers.js/blob/main/src.ts/address/address.ts +function checksumedEthAddress(addressLowerCased: string): string { + const chars = addressLowerCased.split(""); + + const expanded = new Uint8Array(40); + for (let i = 0; i < 40; i++) { + expanded[i] = chars[i].charCodeAt(0); + } + + const hashed = keccak256.digest(expanded); + + for (let i = 0; i < 40; i += 2) { + if (hashed[i >> 1] >> 4 >= 8) { + chars[i] = chars[i].toUpperCase(); + } + if ((hashed[i >> 1] & 0x0f) >= 8) { + chars[i + 1] = chars[i + 1].toUpperCase(); + } + } + + return chars.join(""); +} + +interface Secp256k1Signature { + r: BN; + s: BN; + recoveryParam: number | undefined; +} + +function secp256k1signatureFrom130HexString(hex: string): Secp256k1Signature { + const r = hex.slice(0, 64); + const s = hex.slice(64, 128); + const v = hex.slice(128, 130); + + let recoveryParam: number | null = null; + + if (v === "1c") { + recoveryParam = 1; + } else if (v === "1b") { + recoveryParam = 0; + } else { + throw new InvalidSignatureFormatError(`Invalid recovery param: ${v}. Expected 1c or 1b.`); + } + + return { r: new BN(r, "hex"), s: new BN(s, "hex"), recoveryParam }; +} + +function secp256k1signatureFromDERHexString(hex: string): Secp256k1Signature { + const signature = new Signature(hex, "hex"); + return { r: signature.r, s: signature.s, recoveryParam: undefined }; +} + +function normalizeSecp256k1Signature(s: string): Secp256k1Signature { + // standard format with recovery parameter + if (s.length === 130) { + return secp256k1signatureFrom130HexString(s); + } + + // standard format with recovery parameter, preceded by 0x + if (s.length === 132 && s.startsWith("0x")) { + return secp256k1signatureFrom130HexString(s.slice(2)); + } + + // standard format with recovery parameter, encoded with base64 + if (s.length === 88) { + const hex = Buffer.from(s, "base64").toString("hex"); + if (hex.length === 130) { + return secp256k1signatureFrom130HexString(hex); + } + } + + // DER format, preceded by 0x + if (s.startsWith("0x") && s.length <= 146) { + return secp256k1signatureFromDERHexString(s.slice(2)); + } + + // DER format + if (s.length === 138 || s.length === 140 || s.length === 142 || s.length === 144) { + return secp256k1signatureFromDERHexString(s); + } + + // DER format, encoded with base64 + if (s.length === 96 || s.length === 92) { + const hex = Buffer.from(s, "base64").toString("hex"); + return secp256k1signatureFromDERHexString(hex); + } + + const errorMessage = `Unknown signature format. Expected 88, 92, 96, 130, 132, 138, 140, 142, or 144 characters, but got ${s.length}`; + throw new InvalidSignatureFormatError(errorMessage, { signature: s }); +} + +function signSecp256k1(dataHash: Buffer, privateKey: Buffer, useDer?: "DER"): string { + if (dataHash.length !== 32) { + const msg = `secp256k1 can sign only 32-bytes long data keccak hash (got ${dataHash.length})`; + throw new InvalidDataHashError(msg); + } + + const signature = ecSecp256k1.sign(dataHash, privateKey); + + if (!useDer) { + return ( + signature.r.toString("hex", 32) + + signature.s.toString("hex", 32) + + new BN(signature.recoveryParam === 1 ? 28 : 27).toString("hex", 1) + ); + } else { + const signatureDER = Buffer.from(signature.toDER()); + return signatureDER.toString("hex"); + } +} + +function validateSecp256k1PublicKey(publicKey: Buffer): ec.KeyPair { + try { + return ecSecp256k1.keyFromPublic(publicKey); + } catch (e) { + throw new InvalidKeyError(`Public Key seems to be invalid. Error: ${e?.message ?? e}`); + } +} + +function isValidSecp256k1Signature( + signature: Secp256k1Signature, + dataHash: Buffer, + publicKey: Buffer +): boolean { + if (dataHash.length !== 32) { + const msg = `secp256k1 can sign only 32-bytes long data keccak hash (got ${dataHash.length})`; + throw new InvalidDataHashError(msg); + } + + const pair = validateSecp256k1PublicKey(publicKey); + + return pair.verify(dataHash, signature); +} + +function calculateKeccak256(data: Buffer): Buffer { + return Buffer.from(keccak256.digest(data)); +} + +function getSignature(obj: object, privateKey: Buffer): string { + const data = Buffer.from(getPayloadToSign(obj)); + return signSecp256k1(calculateKeccak256(data), privateKey); +} + +function getDERSignature(obj: object, privateKey: Buffer): string { + const data = Buffer.from(getPayloadToSign(obj)); + return signSecp256k1(calculateKeccak256(data), privateKey, "DER"); +} + +function recoverPublicKey(signature: string, obj: object): string { + const signatureObj = normalizeSecp256k1Signature(signature); + const recoveryParam = signatureObj.recoveryParam; + if (recoveryParam === undefined) { + const message = "Signature must contain recovery part (typically 1b or 1c as the last two characters)"; + throw new InvalidSignatureFormatError(message, { signature }); + } + + const data = Buffer.from(getPayloadToSign(obj)); + const dataHash = Buffer.from(keccak256.hex(data), "hex"); + const publicKeyObj = ecSecp256k1.recoverPubKey(dataHash, signatureObj, recoveryParam); + return publicKeyObj.encode("hex", false); +} + +function isValid(signature: string, obj: object, publicKey: string): boolean { + const data = Buffer.from(getPayloadToSign(obj)); + const publicKeyBuffer = normalizePublicKey(publicKey); + + const signatureObj = normalizeSecp256k1Signature(signature); + const dataHash = Buffer.from(keccak256.hex(data), "hex"); + return isValidSecp256k1Signature(signatureObj, dataHash, publicKeyBuffer); +} + +function validatePublicKey(publicKey: Buffer): void { + validateSecp256k1PublicKey(publicKey); +} + +function enforceValidPublicKey( + signature: string | undefined, + payload: object, + publicKey: string | undefined +): string { + if (signature === undefined) { + throw new InvalidSignatureFormatError(`Signature is ${signature}`, { signature }); + } + + const signatureObj = normalizeSecp256k1Signature(signature); + + if (publicKey === undefined) { + if (signatureObj.recoveryParam === undefined) { + const message = "Public key is required when the signature recovery parameter is missing"; + throw new ValidationFailedError(message, { signature }); + } else { + // recover public key from the signature and payload + return recoverPublicKey(signature, payload); + } + } + + const publicKeyBuffer = normalizePublicKey(publicKey); + const keccakBuffer = calculateKeccak256(Buffer.from(getPayloadToSign(payload))); + + if (isValidSecp256k1Signature(signatureObj, keccakBuffer, publicKeyBuffer)) { + return publicKeyBuffer.toString("hex"); + } else { + throw new ValidationFailedError("Secp256k1 signature is invalid", { signature, publicKey, payload }); + } +} + +export default { + calculateKeccak256, + enforceValidPublicKey, + getCompactBase64PublicKey, + getNonCompactHexPublicKey, + getEthAddress, + getPayloadToSign, + getPublicKey, + getSignature, + getDERSignature, + isValid, + isValidSecp256k1Signature, + normalizePrivateKey, + normalizePublicKey, + normalizeSecp256k1Signature, + recoverPublicKey, + validatePublicKey, + validateSecp256k1PublicKey +} as const; diff --git a/chain-api/src/utils/transform-decorators.spec.ts b/chain-api/src/utils/transform-decorators.spec.ts new file mode 100644 index 000000000..242a9b104 --- /dev/null +++ b/chain-api/src/utils/transform-decorators.spec.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { IsOptional } from "class-validator"; + +import { ChainCallDTO } from "../types"; +import { BigNumberIsInteger, BigNumberIsNotNegative } from "../validators"; +import deserialize from "./deserialize"; +import serialize from "./serialize"; +import { BigNumberProperty } from "./transform-decorators"; + +describe("infinity", () => { + it("validation should give errors when BigNumber is Infinity and has no flag", async () => { + // Given + class MockMintTokenDto extends ChainCallDTO { + @BigNumberProperty() + amount: BigNumber; + } + + const NewFake = new MockMintTokenDto(); + NewFake.amount = new BigNumber(Infinity); + + // When + const output = await NewFake.validate(); + + // Then + expect(output).toEqual([ + expect.objectContaining({ + constraints: expect.objectContaining({ + BigNumberIsNotInfinity: "amount must be finite BigNumber but is Infinity" + }) + }) + ]); + }); + + it("validation should give no errors when BigNumber is Infinity and has allowInfinity flag", async () => { + // Given + class MockMintTokenDto extends ChainCallDTO { + @BigNumberProperty({ allowInfinity: true }) + amount: BigNumber; + } + + const NewFake = new MockMintTokenDto(); + NewFake.amount = new BigNumber(Infinity); + // eslint-disable-next-line + const expectedSerializedSubstring = `{\"amount\":\"Infinity\"}`; + + // When + const output = await NewFake.validate(); + const serialized = serialize(NewFake); + const deserialized = deserialize(MockMintTokenDto, serialized); + + // Then + // check validation errors + expect(output).toEqual([]); + // check serialization and deserialization works properly + expect(serialized).toEqual(expectedSerializedSubstring); + expect(deserialized.amount).toEqual(NewFake.amount); + }); + + it("validation should give no errors when BigNumber is finite", async () => { + // Given + class MockMintTokenDto extends ChainCallDTO { + @BigNumberProperty() + amount: BigNumber; + } + + const NewFake = new MockMintTokenDto(); + NewFake.amount = new BigNumber(5); + // eslint-disable-next-line + const expectedSerializedSubstring = `{\"amount\":\"5\"}`; + + // When + const output = await NewFake.validate(); + const serialized = serialize(NewFake); + const deserialized = deserialize(MockMintTokenDto, serialized); + + // Then + expect(output.length).toEqual(0); + + // Then + // check validation errors + expect(output).toEqual([]); + // check serialization and deserialization works properly + expect(serialized).toEqual(expectedSerializedSubstring); + expect(deserialized.amount).toEqual(NewFake.amount); + }); + + it("validation should give no errors when BigNumber is optional and property is not present", async () => { + // Given + class MockDto extends ChainCallDTO { + @IsOptional() + @BigNumberIsInteger() + @BigNumberIsNotNegative() + @BigNumberProperty() + amount?: BigNumber; + } + + const NewFake = new MockDto(); + // const expectedSerializedString = `{}`; + + // When + const output = await NewFake.validate(); + const serialized = serialize(NewFake); + const deserializezd = deserialize(MockDto, serialized); + + // Then + expect(output.length).toEqual(0); + expect(output).toEqual([]); + expect(deserializezd.amount).toBeUndefined(); + }); +}); diff --git a/chain-api/src/utils/transform-decorators.ts b/chain-api/src/utils/transform-decorators.ts new file mode 100644 index 000000000..0f9b7f5c4 --- /dev/null +++ b/chain-api/src/utils/transform-decorators.ts @@ -0,0 +1,126 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { Transform, Type } from "class-transformer"; +import { IsIn, ValidateBy, ValidationOptions, buildMessage } from "class-validator"; +import "reflect-metadata"; + +import { BigNumberIsNotInfinity, IsBigNumber } from "../validators"; + +type ClassConstructor = { + new (args: Signature): unknown; +}; + +export function ApplyConstructor( + Constructor: ClassConstructor, + fromTransformer: (propertyValue: SerializedType) => ClassInstance, + toTransformer: (classInstance: ClassInstance) => SerializedType +) { + return function Wrapper() { + const type = Type(() => Constructor); + + const from = Transform(({ value }) => fromTransformer(value), { + toClassOnly: true + }); + + const to = Transform(({ value }) => toTransformer(value), { + toPlainOnly: true + }); + + // eslint-disable-next-line @typescript-eslint/ban-types + return function (target: Object, propertyKey: string | symbol) { + type(target, propertyKey); + from(target, propertyKey); + to(target, propertyKey); + }; + }; +} + +// create BigNumber object only if we have proper input that matches .toFixed() +const parseBigNumber = (value: unknown): unknown => { + if (typeof value === "string") { + const bn = new BigNumber(value); + return bn.toFixed() === value ? bn : value; + } else { + return value; + } +}; + +export const BigNumberProperty = (opts?: { allowInfinity: boolean }) => { + const type = Type(() => BigNumber); + + const from = Transform(({ value }) => parseBigNumber(value), { + toClassOnly: true + }); + + const to = Transform(({ value }) => value.toFixed(), { + toPlainOnly: true + }); + + // eslint-disable-next-line @typescript-eslint/ban-types + return function (target: Object, propertyKey) { + type(target, propertyKey); + from(target, propertyKey); + to(target, propertyKey); + + IsBigNumber()(target, propertyKey); + if (!opts?.allowInfinity) { + BigNumberIsNotInfinity()(target, propertyKey); + } + }; +}; + +export const BigNumberArrayProperty = ApplyConstructor( + BigNumber, + (values: string[]) => values.map((value) => parseBigNumber(value)), + (values: BigNumber[]) => values.map((value) => value.toFixed()) +); + +/* + * Mark this field has enum value. Works only for standard enums (numbers as values). + */ +export function EnumProperty(enumType: object, validationOptions?: ValidationOptions) { + // enum obj contains reverse mappings: {"0":"Use", ...,"Use":0, ...} + const keysAndValues = Object.values(enumType); + const values = keysAndValues.filter((v) => typeof v === "number").sort(); + const mappingInfo = values.map((v) => `${v} - ${enumType[v]}`).join(", "); + + return ValidateBy( + { + name: "enumProperty", + constraints: [values, enumType, mappingInfo], // enumType and mappingInfo is added here to use this information outside this lib + validator: { + validate: (value, args) => { + const possibleValues = args?.constraints[0]; + return !Array.isArray(possibleValues) || possibleValues.some((v) => v === value); + }, + defaultMessage: buildMessage( + (prefix) => + `${prefix}$property must be one of the following values: $constraint1, where $constraint3`, + validationOptions + ) + } + }, + validationOptions + ); +} + +/* + * Mark this field has enum value. Works only for string enums (strings as values). + */ +export function StringEnumProperty(enumType: object, validationOptions?: ValidationOptions) { + const values = Object.values(enumType).sort(); + return IsIn(values, validationOptions); +} diff --git a/chain-api/src/validators/decorators.spec.ts b/chain-api/src/validators/decorators.spec.ts new file mode 100644 index 000000000..c08fc8478 --- /dev/null +++ b/chain-api/src/validators/decorators.spec.ts @@ -0,0 +1,90 @@ +/* + * 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 { ArrayNotEmpty } from "class-validator"; + +import { ChainCallDTO } from "../types"; +import { ArrayUniqueObjects } from "./decorators"; + +describe("ArrayUniqueObject", () => { + it("validation should give errors when two users have the same id", async () => { + // Given + class MockDto extends ChainCallDTO { + @ArrayNotEmpty() + @ArrayUniqueObjects("id") + users: unknown[]; + } + + const NewFake = new MockDto(); + NewFake.users = [{ id: 1 }, { id: 1 }, { id: 3 }]; + + // When + const output = await NewFake.validate(); + + // Then + expect(output).toEqual([ + expect.objectContaining({ + constraints: expect.objectContaining({ + ArrayUniqueObjects: "users must not contains duplicate entry for id" + }) + }) + ]); + }); + + it("validation should give errors when two users have the same id", async () => { + // Given + class MockDto extends ChainCallDTO { + @ArrayNotEmpty() + @ArrayUniqueObjects("id") + users: unknown[]; + } + + const NewFake = new MockDto(); + NewFake.users = [ + { id: 1, other: "yee" }, + { id: 1, other: "wee" }, + { id: 3, other: "hee" } + ]; + + // When + const output = await NewFake.validate(); + + // Then + expect(output).toEqual([ + expect.objectContaining({ + constraints: expect.objectContaining({ + ArrayUniqueObjects: "users must not contains duplicate entry for id" + }) + }) + ]); + }); + + it("validation should pass when all users have different ids", async () => { + // Given + class MockDto extends ChainCallDTO { + @ArrayNotEmpty() + @ArrayUniqueObjects("id") + users: unknown[]; + } + + const NewFake = new MockDto(); + NewFake.users = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + // When + const output = await NewFake.validate(); + + // Then + expect(output.length).toEqual(0); + }); +}); diff --git a/chain-api/src/validators/decorators.ts b/chain-api/src/validators/decorators.ts new file mode 100644 index 000000000..9736b0f0e --- /dev/null +++ b/chain-api/src/validators/decorators.ts @@ -0,0 +1,247 @@ +/* + * Copyright (c) Gala Games Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BigNumber from "bignumber.js"; +import { ValidationArguments, ValidationOptions, registerDecorator } from "class-validator"; + +export function IsWholeNumber(property: string, validationOptions?: ValidationOptions) { + return function (object: Record, propertyName: string): void { + registerDecorator({ + name: "isWholeNumber", + target: object.constructor, + propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: Record, args: ValidationArguments) { + const [relatedPropertyName] = args.constraints; + const relatedValue = args.object[relatedPropertyName]; + const num = Number(relatedValue); + + return num - Math.floor(num) !== 0; + } + } + }); + }; +} +export function IsNot( + property: string, + condition: (instance: Record) => unknown, + validationOptions?: ValidationOptions +) { + return function (object: Record, propertyName: string): void { + registerDecorator({ + name: "isNot", + target: object.constructor, + propertyName, + constraints: [property], + options: validationOptions, + validator: { + // eslint-disable-next-line + validate(value: Record, args: ValidationArguments) { + return true; + } + } + }); + }; +} + +export function IsDifferentValue(property: string, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string): void { + registerDecorator({ + name: "IsDifferentValue", + target: object.constructor, + propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: Record, args: ValidationArguments) { + const [relatedPropertyName] = args.constraints; + const relatedValue = args.object[relatedPropertyName]; + return value !== relatedValue; + } + } + }); + }; +} + +export function ArrayUniqueConcat(property: string, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string): void { + registerDecorator({ + name: "ArrayUniqueConcat", + target: object.constructor, + propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: unknown[], args: ValidationArguments) { + const [relatedPropertyName] = args.constraints; + const relatedValue = args.object[relatedPropertyName]; + + // Cannot have tokens duplicated between from or to, or within the from or to itself + const totalUniques = new Set(relatedValue.concat(value)).size; + + return totalUniques === relatedValue.length + value.length; + } + } + }); + }; +} + +export function ArrayUniqueObjects(property: string, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string): void { + registerDecorator({ + name: "ArrayUniqueObjects", + target: object.constructor, + propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: unknown[]): boolean { + if (Array.isArray(value)) { + const propertyValues = value.map((v) => (typeof v === "object" && !!v ? v[property] : undefined)); + return new Set(propertyValues).size === value.length; + } + return false; + }, + defaultMessage(args: ValidationArguments): string { + return `${args.property} must not contains duplicate entry for ${args.constraints[0]}`; + } + } + }); + }; +} + +export function IsBigNumber(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: "IsBigNumber", + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + return BigNumber.isBigNumber(value); + }, + defaultMessage(args: ValidationArguments) { + const bn = + typeof args.value === "string" || typeof args.value === "number" + ? new BigNumber(args.value) + : undefined; + + const suggestion = bn && !bn.isNaN() ? ` (valid value: ${bn?.toFixed()})` : ""; + + return ( + `${args.property} should be a stringified number with fixed notation (not an exponential notation) ` + + `and no trailing zeros in decimal part${suggestion}` + ); + } + } + }); + }; +} + +function validateBigNumberOrIgnore(obj: unknown, fn: (bn: BigNumber) => boolean): boolean { + if (BigNumber.isBigNumber(obj)) { + return fn(obj); + } else { + return true; + } +} + +export function BigNumberIsPositive(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + IsBigNumber(validationOptions)(object, propertyName); + registerDecorator({ + name: "BigNumberIsPositive", + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + return validateBigNumberOrIgnore(value, (b) => b.isPositive()); + }, + defaultMessage(args: ValidationArguments) { + // here you can provide default error message if validation failed + return `${args.property} must be positive but is ${args.value?.toString() ?? args.value}`; + } + } + }); + }; +} + +export function BigNumberIsNotInfinity(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + IsBigNumber(validationOptions)(object, propertyName); + registerDecorator({ + name: "BigNumberIsNotInfinity", + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + return validateBigNumberOrIgnore(value, (b) => b.isFinite()); + }, + defaultMessage(args: ValidationArguments) { + // here you can provide default error message if validation failed + return `${args.property} must be finite BigNumber but is ${args.value?.toString() ?? args.value}`; + } + } + }); + }; +} + +export function BigNumberIsNotNegative(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + IsBigNumber(validationOptions)(object, propertyName); + registerDecorator({ + name: "BigNumberIsNotNegative", + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + return validateBigNumberOrIgnore(value, (b) => !b.isNegative()); + }, + defaultMessage(args: ValidationArguments) { + // here you can provide default error message if validation failed + return `${args.property} must be non-negative BigNumber but is ${ + args.value?.toString() ?? args.value + }`; + } + } + }); + }; +} + +export function BigNumberIsInteger(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + IsBigNumber(validationOptions)(object, propertyName); + registerDecorator({ + name: "BigNumberIsInteger", + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + return validateBigNumberOrIgnore(value, (b) => b.isInteger()); + }, + defaultMessage(args: ValidationArguments) { + // here you can provide default error message if validation failed + return `${args.property} must be integer BigNumber but is ${args.value?.toString() ?? args.value}`; + } + } + }); + }; +} diff --git a/chain-api/src/validators/index.ts b/chain-api/src/validators/index.ts new file mode 100644 index 000000000..395a40242 --- /dev/null +++ b/chain-api/src/validators/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ + +export * from "./decorators"; diff --git a/chain-api/tsconfig.json b/chain-api/tsconfig.json new file mode 100644 index 000000000..c08bf19ff --- /dev/null +++ b/chain-api/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "lib", + "declarationDir": "lib", + "composite": true + }, + "files": [], + "include": ["**/*", "package.json"], + "exclude": ["lib", "**/*spec.ts", "src/__mocks__", "src/__test__"], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/chain-api/tsconfig.lib.json b/chain-api/tsconfig.lib.json new file mode 100644 index 000000000..12d2c568e --- /dev/null +++ b/chain-api/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "lib", + "declarationDir": "lib", + "experimentalDecorators": true, + "composite": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/chain-api/tsconfig.spec.json b/chain-api/tsconfig.spec.json new file mode 100644 index 000000000..d7c1c989f --- /dev/null +++ b/chain-api/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/chain-cli/.eslintignore b/chain-cli/.eslintignore new file mode 100644 index 000000000..f3bd2c654 --- /dev/null +++ b/chain-cli/.eslintignore @@ -0,0 +1,3 @@ +node_modules +lib +coverage \ No newline at end of file diff --git a/chain-cli/.eslintrc.json b/chain-cli/.eslintrc.json new file mode 100644 index 000000000..ce3a6b8c9 --- /dev/null +++ b/chain-cli/.eslintrc.json @@ -0,0 +1,29 @@ +{ + "extends": ["../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "rules": { + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-explicit-any": "warn" + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/chain-cli/.gitignore b/chain-cli/.gitignore new file mode 100644 index 000000000..0c348f50a --- /dev/null +++ b/chain-cli/.gitignore @@ -0,0 +1 @@ +.npmrc \ No newline at end of file diff --git a/chain-cli/README.md b/chain-cli/README.md new file mode 100644 index 000000000..99157de7f --- /dev/null +++ b/chain-cli/README.md @@ -0,0 +1,520 @@ +galachain +================= + +CLI tool for Gala chaincode + +[![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io) +[![CircleCI](https://circleci.com/gh/oclif/hello-world/tree/main.svg?style=shield)](https://circleci.com/gh/oclif/hello-world/tree/main) +[![GitHub license](https://img.shields.io/github/license/oclif/hello-world)](https://github.com/oclif/hello-world/blob/main/LICENSE) + + +* [Usage](#usage) +* [Commands](#commands) + +# Usage + +```sh-session +$ npm install -g @gala-chain/cli +$ galachain COMMAND +running command... +$ galachain (--version) +@gala-chain/cli/1.0.0 linux-x64 node-v16.20.2 +$ galachain --help [COMMAND] +USAGE + $ galachain COMMAND +... +``` + +# Commands + +* [`galachain connect [DEVELOPERPRIVATEKEY]`](#galachain-connect-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 network-prune`](#galachain-network-prune) +* [`galachain network-up`](#galachain-network-up) +* [`galachain network:prune`](#galachain-networkprune) +* [`galachain network:up`](#galachain-networkup) +* [`galachain test-deploy IMAGETAG [DEVELOPERPRIVATEKEY]`](#galachain-test-deploy-imagetag-developerprivatekey) + +## `galachain connect [DEVELOPERPRIVATEKEY]` + +Connect to a new chaincode. + +``` +USAGE + $ galachain connect [DEVELOPERPRIVATEKEY] [--json] [--log-level debug|info|warn|error] + +ARGUMENTS + DEVELOPERPRIVATEKEY Optional private key to sign the data. It could be a file or a string. If not provided, the + private key will be read from the environment variable DEV_PRIVATE_KEY. + +GLOBAL FLAGS + --json Format output as json. + --log-level=