diff --git a/.dockerignore b/.dockerignore index 45229ed5..ca78daec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ .git .env* -node_modules/ \ No newline at end of file +node_modules/ +/dist \ No newline at end of file diff --git a/.github/workflows/build-publish-image.yml b/.github/workflows/build-publish-image.yml new file mode 100644 index 00000000..0a1c1e9b --- /dev/null +++ b/.github/workflows/build-publish-image.yml @@ -0,0 +1,68 @@ +name: Build and publish image to Docker Hub and ECR + +on: + push: + tags: + - v* + +permissions: + id-token: write + contents: read + +env: + AWS_REGION: us-west-2 + ECR_REPOSITORY: polymesh/polymesh-rest-api + +jobs: + publish: + name: Build and push image + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out the repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set package version + run: ./scripts/set-package-version.sh + + - name: Set up QEMU + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to Docker Hub + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + username: ${{ secrets.ASSOCIATION_DOCKER_USERNAME }} + password: ${{ secrets.ASSOCIATION_DOCKER_TOKEN }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + with: + role-to-assume: ${{ vars.ECR_PUSH_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Log in to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6 + + - name: Extract metadata (tags, labels) for Docker Hub + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ secrets.ASSOCIATION_DOCKER_HUB_REPO }} + + - name: Build and push image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + provenance: true + sbom: true + tags: | + ${{ steps.meta.outputs.tags }} + ${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.ref_name }} + ${{ steps.ecr-login.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 2cb2525c..00000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Publish Docker image to docker hub - -on: - push: - tags: - - v* - -jobs: - push_to_registry: - name: Push Docker image to Docker Hub - runs-on: ubuntu-latest - steps: - - name: Check out the repo - uses: actions/checkout@v4 - - name: Set package version - run: ./scripts/set-package-version.sh - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Log in to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.ASSOCIATION_DOCKER_USERNAME }} - password: ${{ secrets.ASSOCIATION_DOCKER_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v3 - with: - images: ${{ secrets.ASSOCIATION_DOCKER_HUB_REPO }} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/README.md b/README.md index 810c793d..28c2aa8e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A REST API wrapper for the Polymesh blockchain. -This version is compatible with chain versions 7.3.x - 8.x +This version is compatible with chain versions 8.0.x - 8.1.x. Chain v7 is no longer supported — the SDK throws on initialization if pointed at a v7 node. ## Setup @@ -65,6 +65,7 @@ $ yarn test:cov PORT=## port in which the server will listen. Defaults to 3000 ## POLYMESH_NODE_URL=## websocket URL for a Polymesh node ## POLYMESH_MIDDLEWARE_V2_URL=## URL for an instance of the Polymesh GraphQL Middleware Native SubQuery service ## +POLYMESH_MIDDLEWARE_V2_API_KEY=## optional API key for the Middleware V2 GraphQL service, if it requires authentication ## POLYMESH_MIDDLEWARE_URL=## URL for an instance of the Polymesh GraphQL Middleware service @deprecated in favour of POLYMESH_MIDDLEWARE_V2_URL## POLYMESH_MIDDLEWARE_API_KEY=## API key for the Middleware GraphQL service ## LOCAL_SIGNERS=## list of comma separated IDs to refer to the corresponding mnemonic ## @@ -73,7 +74,7 @@ LOCAL_MNEMONICS=## list of comma separated mnemonics for the signer service (eac # Below are optional params that enable some features. The above should be good to get started with DEVELOPER_SUDO_MNEMONIC=## a mnemonic that has `sudo` privileges for a chain. Defaults to `//Alice` ## -DEVELOPER_UTILS=## set to `true` to enable developer testing endpoints ## +DEVELOPER_UTILS=## set to `true` to enable developer testing endpoints. Never enable this in production ## # Vault Signer: VAULT_URL=## The URL of a Vault transit engine## @@ -93,6 +94,8 @@ NOTIFICATIONS_LEGITIMACY_SECRET=## A secret used to create HMAC signatures ## # Auth: AUTH_STRATEGY=## list of comma separated auth strategies to use e.g. (`apiKey,open`) ## API_KEYS=## list of comma separated api keys to initialize the `apiKey` strategy with ## +AUTH_MANAGEMENT_ENABLED=## `true` or `false` to expose the user and API key management endpoints (`/users/create`, `/auth/api-key/*`). Defaults to `false` when `AUTH_STRATEGY` includes `open`, otherwise `true` ## +SIGNER_MANAGEMENT_ENABLED=## `true` or `false` to expose the endpoint that adds signing keys (`POST /signer`). Defaults to `false` when `AUTH_STRATEGY` includes `open`, otherwise `true` ## # Datastore: REST_POSTGRES_HOST=## Domain or IP of DB instance ## REST_POSTGRES_PORT=## Port the DB is listening (usually 5432) ## diff --git a/package.json b/package.json index bead5571..dcddf53a 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@polymeshassociation/fireblocks-signing-manager": "^3.0.0", "@polymeshassociation/hashicorp-vault-signing-manager": "^4.1.0", "@polymeshassociation/local-signing-manager": "^4.1.1", - "@polymeshassociation/polymesh-sdk": "v30.0.0", - "@polymeshassociation/polymesh-types": "^7.4.0", + "@polymeshassociation/polymesh-sdk": "31.1.0-beta.8", + "@polymeshassociation/polymesh-types": "^7.5.0", "@polymeshassociation/signing-manager-types": "^3.7.1", "class-transformer": "0.5.1", "class-validator": "^0.14.0", diff --git a/src/accounts/accounts.controller.spec.ts b/src/accounts/accounts.controller.spec.ts index f4e1abbb..88d9c4f4 100644 --- a/src/accounts/accounts.controller.spec.ts +++ b/src/accounts/accounts.controller.spec.ts @@ -94,6 +94,8 @@ describe('AccountsController', () => { free: new BigNumber(10), locked: new BigNumber(1), total: new BigNumber(11), + reserved: new BigNumber(1), + frozen: new BigNumber(0), }; mockAccountsService.getAccountBalance.mockResolvedValue(mockResult); diff --git a/src/accounts/accounts.controller.ts b/src/accounts/accounts.controller.ts index c1db36b7..6ccc7d71 100644 --- a/src/accounts/accounts.controller.ts +++ b/src/accounts/accounts.controller.ts @@ -28,6 +28,7 @@ import { ModifyPermissionsDto } from '~/accounts/dto/modify-permissions.dto'; import { RevokePermissionsDto } from '~/accounts/dto/revoke-permissions.dto'; import { TransactionHistoryFiltersDto } from '~/accounts/dto/transaction-history-filters.dto'; import { TransferPolyxDto } from '~/accounts/dto/transfer-polyx.dto'; +import { AccountBalanceModel } from '~/accounts/models/account-balance.model'; import { AccountCollectionModel, createAccountCollectionModel, @@ -36,7 +37,6 @@ import { AccountDetailsModel } from '~/accounts/models/account-details.model'; import { MultiSigDetailsModel } from '~/accounts/models/multi-sig-details.model'; import { PermissionsModel } from '~/accounts/models/permissions.model'; import { AssetBalanceModel } from '~/assets/models/asset-balance.model'; -import { BalanceModel } from '~/assets/models/balance.model'; import { ApiArrayResponse, ApiTransactionResponse } from '~/common/decorators/'; import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; import { ExtrinsicModel } from '~/common/models/extrinsic.model'; @@ -106,7 +106,8 @@ export class AccountsController { @ApiOperation({ summary: 'Get POLYX balance of an Account', - description: 'This endpoint provides the free, locked and total POLYX balance of an Account', + description: + 'This endpoint provides the free, locked and total POLYX balance of an Account. Note: as of chain v8, `locked` also includes funds on hold (e.g. bonded for staking), which is reflected in the `reserved` and `frozen` fields', }) @ApiParam({ name: 'account', @@ -116,12 +117,12 @@ export class AccountsController { }) @ApiOkResponse({ description: 'Free, locked and total POLYX balance of the Account', - type: BalanceModel, + type: AccountBalanceModel, }) @Get(':account/balance') - async getAccountBalance(@Param() { account }: AccountParamsDto): Promise { + async getAccountBalance(@Param() { account }: AccountParamsDto): Promise { const accountBalance = await this.accountsService.getAccountBalance(account); - return new BalanceModel(accountBalance); + return new AccountBalanceModel(accountBalance); } @ApiOperation({ @@ -136,7 +137,6 @@ export class AccountsController { description: '', }) @@ -232,8 +232,7 @@ export class AccountsController { @ApiOperation({ summary: 'Get pending subsidies for an Account', - description: - 'Returns pending subsidy requests awaiting acceptance by the beneficiary account on chain v8', + description: 'Returns pending subsidy requests awaiting acceptance by the beneficiary account', }) @ApiParam({ name: 'account', @@ -258,7 +257,7 @@ export class AccountsController { @ApiOperation({ summary: 'Get fungible asset balances held by an Account', - description: 'Returns balances of all fungible assets held in the Account on chain v8', + description: 'Returns balances of all fungible assets held in the Account', }) @ApiParam({ name: 'account', @@ -291,7 +290,7 @@ export class AccountsController { @ApiOperation({ summary: 'Get NFT collections held by an Account', - description: 'Returns NFT collections and holdings for the Account on chain v8', + description: 'Returns NFT collections and holdings for the Account', }) @ApiParam({ name: 'account', diff --git a/src/accounts/models/account-balance.model.ts b/src/accounts/models/account-balance.model.ts new file mode 100644 index 00000000..b77c7221 --- /dev/null +++ b/src/accounts/models/account-balance.model.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; + +import { BalanceModel } from '~/assets/models/balance.model'; +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class AccountBalanceModel extends BalanceModel { + @ApiProperty({ + type: 'string', + description: + 'POLYX placed on hold by the protocol, e.g. bonded for staking. Not part of `free` and cannot be spent until released', + example: '100', + }) + @FromBigNumber() + readonly reserved: BigNumber; + + @ApiProperty({ + type: 'string', + description: + 'Minimum balance (out of `total`) that must remain in the Account due to freezes/locks (e.g. vesting). May overlap with `reserved`', + example: '0', + }) + @FromBigNumber() + readonly frozen: BigNumber; + + constructor(model: AccountBalanceModel) { + super(model); + Object.assign(this, model); + } +} diff --git a/src/app.module.ts b/src/app.module.ts index ccd40ff6..7bfdff5a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { PolymeshModule } from '~/polymesh/polymesh.module'; import { PortfoliosModule } from '~/portfolios/portfolios.module'; import { ScheduleModule } from '~/schedule/schedule.module'; import { SettlementsModule } from '~/settlements/settlements.module'; +import { SignerManagementModule } from '~/signing/signer-management.module'; import { SigningModule } from '~/signing/signing.module'; import { SubscriptionsModule } from '~/subscriptions/subscriptions.module'; import { SubsidyModule } from '~/subsidy/subsidy.module'; @@ -47,6 +48,7 @@ import { UsersModule } from '~/users/users.module'; POLYMESH_NODE_URL: Joi.string().required(), POLYMESH_MIDDLEWARE_URL: Joi.string(), POLYMESH_MIDDLEWARE_API_KEY: Joi.string(), + POLYMESH_MIDDLEWARE_V2_API_KEY: Joi.string(), SUBSCRIPTIONS_TTL: Joi.number().default(60000), SUBSCRIPTIONS_MAX_HANDSHAKE_TRIES: Joi.number().default(5), SUBSCRIPTIONS_HANDSHAKE_RETRY_INTERVAL: Joi.number().default(5000), @@ -59,6 +61,8 @@ import { UsersModule } from '~/users/users.module'; VAULT_URL: Joi.string().allow(''), DEVELOPER_SUDO_MNEMONIC: Joi.string().default('//Alice'), DEVELOPER_UTILS: Joi.bool().default(false), + AUTH_MANAGEMENT_ENABLED: Joi.bool(), + SIGNER_MANAGEMENT_ENABLED: Joi.bool(), API_KEYS: Joi.string().default(''), AUTH_STRATEGY: Joi.string().default(() => { if (process.env.NODE_ENV === 'production') { @@ -97,8 +101,9 @@ import { UsersModule } from '~/users/users.module'; NotificationsModule, ScheduleModule, NetworkModule, - AuthModule, - UsersModule, + AuthModule.register(), + UsersModule.register(), + SignerManagementModule.register(), DeveloperTestingModule.register(), MetadataModule, SubsidyModule, diff --git a/src/assets/assets.controller.spec.ts b/src/assets/assets.controller.spec.ts index a0148f6f..5ef0610d 100644 --- a/src/assets/assets.controller.spec.ts +++ b/src/assets/assets.controller.spec.ts @@ -20,6 +20,7 @@ import { AssetsService } from '~/assets/assets.service'; import { AssetDocumentDto } from '~/assets/dto/asset-document.dto'; import { SetStatsDto } from '~/assets/dto/transfer-restrictions/set-stats.dto'; import { SetTransferRestrictionsDto } from '~/assets/dto/transfer-restrictions/set-transfer-restrictions.dto'; +import { IssuedInFundingRoundModel } from '~/assets/models/issued-in-funding-round.model'; import { StatAccreditedClaimModel } from '~/assets/models/stat-claim-accredited.model'; import { StatAffiliateClaimModel } from '~/assets/models/stat-claim-affiliate.model'; import { StatJurisdictionClaimModel } from '~/assets/models/stat-claim-jurisdiction.model'; @@ -36,7 +37,7 @@ import { ProcessMode } from '~/common/types'; import { MetadataService } from '~/metadata/metadata.service'; import { PortfolioDto } from '~/portfolios/dto/portfolio.dto'; import { processedTxResult, testValues } from '~/test-utils/consts'; -import { MockAsset, MockAuthorizationRequest } from '~/test-utils/mocks'; +import { MockAsset, MockAuthorizationRequest, MockInstruction } from '~/test-utils/mocks'; import { MockAssetService, mockMetadataServiceProvider } from '~/test-utils/service-mocks'; const { signer, did, txResult, assetId } = testValues; @@ -437,6 +438,33 @@ describe('AssetsController', () => { expect(result).toEqual(processedTxResult); expect(mockAssetsService.transferFunds).toHaveBeenCalledWith(body); }); + + it('should return the pending Instruction when the transfer does not settle immediately', async () => { + const from = new AssetHolderDto({ + type: AssetHolderType.account, + address: 'fromAddress', + }); + const to = new AssetHolderDto({ + type: AssetHolderType.account, + address: 'toAddress', + }); + const amount = new BigNumber(100); + const body = { signer, from, to, asset: assetId, amount }; + + const mockInstruction = new MockInstruction(); + + mockAssetsService.transferFunds.mockResolvedValue({ + ...txResult, + result: mockInstruction, + }); + + const result = await controller.transferFunds(body); + + expect(result).toEqual({ + ...processedTxResult, + instruction: mockInstruction, + }); + }); }); describe('approveAllowance', () => { @@ -519,6 +547,21 @@ describe('AssetsController', () => { }); }); + describe('getIssuedInFundingRound', () => { + it('should call the service and return the amount issued in the funding round', async () => { + mockAssetsService.getIssuedInFundingRound.mockResolvedValue(new BigNumber(1000)); + + const result = await controller.getIssuedInFundingRound({ + asset: assetId, + round: 'Series A', + }); + + expect(result).toEqual( + new IssuedInFundingRoundModel({ fundingRound: 'Series A', issued: new BigNumber(1000) }) + ); + }); + }); + describe('addRequiredMediators', () => { it('should call the service and return the results', async () => { const mediators = ['someDid']; diff --git a/src/assets/assets.controller.ts b/src/assets/assets.controller.ts index 91ee9a7f..96034274 100644 --- a/src/assets/assets.controller.ts +++ b/src/assets/assets.controller.ts @@ -10,7 +10,7 @@ import { ApiTags, ApiUnprocessableEntityResponse, } from '@nestjs/swagger'; -import { Asset } from '@polymeshassociation/polymesh-sdk/types'; +import { Asset, Instruction } from '@polymeshassociation/polymesh-sdk/types'; import { AssetsService } from '~/assets/assets.service'; import { createAssetDetailsModel } from '~/assets/assets.util'; @@ -18,6 +18,7 @@ import { ApproveAllowanceDto } from '~/assets/dto/approve-allowance.dto'; import { AssetParamsDto } from '~/assets/dto/asset-params.dto'; import { ControllerTransferDto } from '~/assets/dto/controller-transfer.dto'; import { CreateAssetDto } from '~/assets/dto/create-asset.dto'; +import { FundingRoundParamsDto } from '~/assets/dto/funding-round-params.dto'; import { GetAllowanceParamsDto } from '~/assets/dto/get-allowance-params.dto'; import { IssueDto } from '~/assets/dto/issue.dto'; import { LinkTickerDto } from '~/assets/dto/link-ticker.dto'; @@ -34,7 +35,9 @@ import { AssetDocumentModel } from '~/assets/models/asset-document.model'; import { AssetStatModel } from '~/assets/models/asset-stat.model'; import { CreatedAssetModel } from '~/assets/models/created-asset.model'; import { IdentityBalanceModel } from '~/assets/models/identity-balance.model'; +import { IssuedInFundingRoundModel } from '~/assets/models/issued-in-funding-round.model'; import { RequiredMediatorsModel } from '~/assets/models/required-mediators.model'; +import { TransferFundsResultModel } from '~/assets/models/transfer-funds-result.model'; import { TransferRestrictionsModel } from '~/assets/models/transfer-restrictions.model'; import { TransferRestrictionsValueModel } from '~/assets/models/transfer-restrictions-values.model'; import { VenueFilteringDetailsModel } from '~/assets/models/venue-filtering-details.model'; @@ -84,16 +87,24 @@ export class AssetsController { @ApiOperation({ summary: 'Transfer funds between asset holders', description: - 'Transfers fungible tokens or NFTs between asset holders (accounts or portfolios) owned by the same identity', + 'Transfers fungible tokens or NFTs between asset holders (accounts or portfolios). The holders may belong to different Identities, in which case a settlement Instruction is created and auto affirmed on behalf of the sender. It settles in the same transaction if the receiving Identity affirms automatically, otherwise the returned Instruction is left pending the receiver affirmation', }) @ApiTransactionResponse({ - description: 'Details about the transaction', - type: TransactionQueueModel, + description: + 'Details about the transaction, along with the pending Instruction if the transfer did not settle immediately', + type: TransferFundsResultModel, }) @Post('transfer-funds') public async transferFunds(@Body() params: TransferFundsDto): Promise { const result = await this.assetsService.transferFunds(params); - return handleServiceResult(result); + + const resolver: TransactionResolver = ({ + result: instruction, + transactions, + details, + }) => new TransferFundsResultModel({ instruction, transactions, details }); + + return handleServiceResult(result, resolver); } @ApiTags('nfts') @@ -569,8 +580,7 @@ export class AssetsController { @ApiOperation({ summary: 'Approve fungible asset allowance', - description: - 'Approves a spender account allowance for transferring fungible asset tokens on chain v8', + description: 'Approves a spender account allowance for transferring fungible asset tokens', }) @ApiParam({ name: 'asset', @@ -665,6 +675,36 @@ export class AssetsController { return new RequiredMediatorsModel({ mediators }); } + @ApiOperation({ + summary: 'Get the total amount issued in a funding round', + description: + 'This endpoint returns the total amount of the Asset issued in the given funding round', + }) + @ApiParam({ + name: 'asset', + description: 'The Asset (Ticker/Asset ID) whose issued amount is to be fetched', + type: 'string', + example: '3616b82e-8e10-80ae-dc95-2ea28b9db8b3', + }) + @ApiParam({ + name: 'round', + description: 'The name of the funding round', + type: 'string', + example: 'Series A', + }) + @ApiOkResponse({ + description: 'The total amount of the Asset issued in the funding round', + type: IssuedInFundingRoundModel, + }) + @Get(':asset/funding-rounds/:round/issued') + public async getIssuedInFundingRound( + @Param() { asset, round }: FundingRoundParamsDto + ): Promise { + const issued = await this.assetsService.getIssuedInFundingRound(asset, round); + + return new IssuedInFundingRoundModel({ fundingRound: round, issued }); + } + @ApiOperation({ summary: 'Add required mediators', description: diff --git a/src/assets/assets.service.spec.ts b/src/assets/assets.service.spec.ts index dc554088..4a8f0ef5 100644 --- a/src/assets/assets.service.spec.ts +++ b/src/assets/assets.service.spec.ts @@ -808,6 +808,21 @@ describe('AssetsService', () => { }); }); + describe('getIssuedInFundingRound', () => { + it('should return the amount issued in the funding round', async () => { + const mockAsset = new MockAsset(); + mockAsset.getIssuedInFundingRound.mockResolvedValue(new BigNumber(1000)); + + const findOneSpy = jest.spyOn(service, 'findOne'); + findOneSpy.mockResolvedValue(mockAsset as unknown as FungibleAsset); + + const result = await service.getIssuedInFundingRound('TICKER', 'Series A'); + + expect(result).toEqual(new BigNumber(1000)); + expect(mockAsset.getIssuedInFundingRound).toHaveBeenCalledWith('Series A'); + }); + }); + describe('addRequiredMediators', () => { it('should run a addRequiredMediators procedure and return the transaction results', async () => { const mockTransaction = setupMockTransaction( diff --git a/src/assets/assets.service.ts b/src/assets/assets.service.ts index 76c7ac37..2a8c5b6f 100644 --- a/src/assets/assets.service.ts +++ b/src/assets/assets.service.ts @@ -10,6 +10,7 @@ import { HistoricAgentOperation, Identity, IdentityBalance, + Instruction, NftCollection, ResultSet, TransferRestrictionParams, @@ -265,7 +266,7 @@ export class AssetsService { ); } - public async transferFunds(params: TransferFundsDto): ServiceReturn { + public async transferFunds(params: TransferFundsDto): ServiceReturn { const { options, args } = extractTxOptions(params); const { from, to, asset, amount, nfts, memo } = args; @@ -323,6 +324,14 @@ export class AssetsService { }); } + public async getIssuedInFundingRound( + assetInput: string, + fundingRound: string + ): Promise { + const asset = await this.findOne(assetInput); + return asset.getIssuedInFundingRound(fundingRound); + } + public async addRequiredMediators( assetInput: string, params: RequiredMediatorsDto diff --git a/src/assets/dto/funding-round-params.dto.ts b/src/assets/dto/funding-round-params.dto.ts new file mode 100644 index 00000000..9506482c --- /dev/null +++ b/src/assets/dto/funding-round-params.dto.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ + +import { IsString } from 'class-validator'; + +import { AssetParamsDto } from '~/assets/dto/asset-params.dto'; + +export class FundingRoundParamsDto extends AssetParamsDto { + @IsString() + readonly round: string; +} diff --git a/src/assets/models/asset-document-with-id.model.ts b/src/assets/models/asset-document-with-id.model.ts new file mode 100644 index 00000000..52cdbdeb --- /dev/null +++ b/src/assets/models/asset-document-with-id.model.ts @@ -0,0 +1,22 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; + +import { AssetDocumentModel } from '~/assets/models/asset-document.model'; +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class AssetDocumentWithIdModel extends AssetDocumentModel { + @ApiProperty({ + description: 'The on-chain ID of the document', + type: 'string', + example: '1', + }) + @FromBigNumber() + readonly id: BigNumber; + + constructor(model: AssetDocumentWithIdModel) { + super(model); + Object.assign(this, model); + } +} diff --git a/src/assets/models/issued-in-funding-round.model.ts b/src/assets/models/issued-in-funding-round.model.ts new file mode 100644 index 00000000..91985382 --- /dev/null +++ b/src/assets/models/issued-in-funding-round.model.ts @@ -0,0 +1,26 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; + +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class IssuedInFundingRoundModel { + @ApiProperty({ + description: 'The name of the funding round', + example: 'Series A', + }) + readonly fundingRound: string; + + @ApiProperty({ + description: 'The total amount of the Asset issued in the funding round', + type: 'string', + example: '1000', + }) + @FromBigNumber() + readonly issued: BigNumber; + + constructor(model: IssuedInFundingRoundModel) { + Object.assign(this, model); + } +} diff --git a/src/assets/models/transfer-funds-result.model.ts b/src/assets/models/transfer-funds-result.model.ts new file mode 100644 index 00000000..46ddb024 --- /dev/null +++ b/src/assets/models/transfer-funds-result.model.ts @@ -0,0 +1,25 @@ +/* istanbul ignore file */ + +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Instruction } from '@polymeshassociation/polymesh-sdk/types'; + +import { FromEntityObject } from '~/common/decorators/transformation'; +import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; + +export class TransferFundsResultModel extends TransactionQueueModel { + @ApiPropertyOptional({ + type: 'string', + description: + 'ID of the settlement Instruction awaiting the receiving Identity affirmation. Absent when the transfer settled immediately, which is the case for a transfer within the same Identity or when the receiving Identity has automatic affirmation enabled', + example: '123', + }) + @FromEntityObject() + readonly instruction?: Instruction; + + constructor(model: TransferFundsResultModel) { + const { transactions, details, ...rest } = model; + super({ transactions, details }); + + Object.assign(this, rest); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index d7895aad..af5cdb35 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ -import { Module } from '@nestjs/common'; +import { DynamicModule, Logger, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; import { IAuthGuard, PassportModule } from '@nestjs/passport'; @@ -10,6 +10,7 @@ import { AuthService } from '~/auth/auth.service'; import { createAuthGuard } from '~/auth/auth.utils'; import { ApiKeyStrategy } from '~/auth/strategies/api-key.strategy'; import { OpenStrategy } from '~/auth/strategies/open.strategy'; +import { isAuthManagementEnabled } from '~/common/utils/feature-flags'; import { DatastoreModule } from '~/datastore/datastore.module'; import { UsersModule } from '~/users/users.module'; @@ -17,30 +18,47 @@ import { UsersModule } from '~/users/users.module'; * responsible for the REST API's authentication strategies * * @note authorization has not yet been implemented - all users have full access + * @note `AuthController` is only registered when `AUTH_MANAGEMENT_ENABLED` resolves to true */ -@Module({ - imports: [ - ConfigModule, - DatastoreModule.registerAsync(), - UsersModule, - PassportModule.register({ - session: false, - }), - ], - providers: [ - AuthService, - ApiKeyStrategy, - OpenStrategy, - { - provide: APP_GUARD, // registers a global guard - useFactory: (config: ConfigService): IAuthGuard => { - const configuredStrategies = config.getOrThrow('AUTH_STRATEGY'); - return createAuthGuard(configuredStrategies); - }, - inject: [ConfigService], - }, - ], - controllers: [AuthController], - exports: [AuthService, PassportModule], -}) -export class AuthModule {} +@Module({}) +export class AuthModule { + /** + * @note must only be called after `ConfigModule.forRoot` has loaded the environment, i.e. not at file import time + */ + static register(): DynamicModule { + const enabled = isAuthManagementEnabled(); + + if (enabled) { + new Logger(AuthModule.name).warn( + 'API key management endpoints are enabled. Make sure they are not reachable by untrusted callers' + ); + } + + return { + module: AuthModule, + imports: [ + ConfigModule, + DatastoreModule.registerAsync(), + UsersModule.register(), + PassportModule.register({ + session: false, + }), + ], + providers: [ + AuthService, + ApiKeyStrategy, + OpenStrategy, + { + provide: APP_GUARD, // registers a global guard + useFactory: (config: ConfigService): IAuthGuard => { + const configuredStrategies = config.getOrThrow('AUTH_STRATEGY'); + return createAuthGuard(configuredStrategies); + }, + inject: [ConfigService], + }, + ], + exports: [AuthService, PassportModule], + controllers: enabled ? [AuthController] : [], + }; + } +} diff --git a/src/checkpoints/checkpoints.controller.spec.ts b/src/checkpoints/checkpoints.controller.spec.ts index fd6d90b2..28edc901 100644 --- a/src/checkpoints/checkpoints.controller.spec.ts +++ b/src/checkpoints/checkpoints.controller.spec.ts @@ -1,3 +1,4 @@ +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; @@ -6,6 +7,7 @@ import { CheckpointsController } from '~/checkpoints/checkpoints.controller'; import { CheckpointsService } from '~/checkpoints/checkpoints.service'; import { CheckpointDetailsModel } from '~/checkpoints/models/checkpoint-details.model'; import { CheckpointScheduleModel } from '~/checkpoints/models/checkpoint-schedule.model'; +import { NextCheckpointModel } from '~/checkpoints/models/next-checkpoint.model'; import { PeriodComplexityModel } from '~/checkpoints/models/period-complexity.model'; import { ScheduleComplexityModel } from '~/checkpoints/models/schedule-complexity.model'; import { PaginatedResultsModel } from '~/common/models/paginated-results.model'; @@ -161,6 +163,29 @@ describe('CheckpointsController', () => { }); }); + describe('getNextCheckpoint', () => { + it('should return the next Checkpoint details', async () => { + const mockNextCheckpoint = { + nextAt: new Date('10/14/1987'), + totalPending: new BigNumber(1), + schedules: [{ id: new BigNumber(1), nextAt: new Date('10/14/1987') }], + }; + mockCheckpointsService.getNextCheckpoint.mockResolvedValue(mockNextCheckpoint); + + const result = await controller.getNextCheckpoint({ asset: assetId }); + + expect(result).toEqual(new NextCheckpointModel(mockNextCheckpoint)); + }); + + it('should throw NotFoundException if the Asset has no active Schedules', async () => { + mockCheckpointsService.getNextCheckpoint.mockResolvedValue(null); + + await expect(controller.getNextCheckpoint({ asset: assetId })).rejects.toThrow( + NotFoundException + ); + }); + }); + describe('getSchedule', () => { it('should call the service and return the Checkpoint Schedule details', async () => { const mockDate = new Date('10/14/1987'); diff --git a/src/checkpoints/checkpoints.controller.ts b/src/checkpoints/checkpoints.controller.ts index aa44f76c..d6f2b012 100644 --- a/src/checkpoints/checkpoints.controller.ts +++ b/src/checkpoints/checkpoints.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, NotFoundException, Param, Post, Query } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, @@ -22,6 +22,7 @@ import { CheckpointDetailsModel } from '~/checkpoints/models/checkpoint-details. import { CheckpointScheduleModel } from '~/checkpoints/models/checkpoint-schedule.model'; import { CreatedCheckpointModel } from '~/checkpoints/models/created-checkpoint.model'; import { CreatedCheckpointScheduleModel } from '~/checkpoints/models/created-checkpoint-schedule.model'; +import { NextCheckpointModel } from '~/checkpoints/models/next-checkpoint.model'; import { PeriodComplexityModel } from '~/checkpoints/models/period-complexity.model'; import { ScheduleComplexityModel } from '~/checkpoints/models/schedule-complexity.model'; import { ApiArrayResponse, ApiTransactionResponse } from '~/common/decorators/'; @@ -176,6 +177,35 @@ export class CheckpointsController { }); } + @ApiOperation({ + summary: "Fetch the closest upcoming Checkpoint across an Asset's active Schedules", + description: + 'This endpoint returns the closest upcoming Checkpoint across all of the given Asset active Schedules', + }) + @ApiParam({ + name: 'asset', + description: 'The Asset (Ticker/Asset ID) whose next Checkpoint is to be fetched', + type: 'string', + example: '3616b82e-8e10-80ae-dc95-2ea28b9db8b3', + }) + @ApiOkResponse({ + description: 'The next Checkpoint details', + type: NextCheckpointModel, + }) + @ApiNotFoundResponse({ + description: 'The Asset has no active Schedules', + }) + @Get('schedules/next') + public async getNextCheckpoint(@Param() { asset }: AssetParamsDto): Promise { + const nextCheckpoint = await this.checkpointsService.getNextCheckpoint(asset); + + if (!nextCheckpoint) { + throw new NotFoundException('The Asset has no active Schedules'); + } + + return new NextCheckpointModel(nextCheckpoint); + } + @ApiOperation({ summary: 'Fetch details of an Asset Checkpoint Schedule', }) diff --git a/src/checkpoints/checkpoints.service.spec.ts b/src/checkpoints/checkpoints.service.spec.ts index b77d39df..51c4c339 100644 --- a/src/checkpoints/checkpoints.service.spec.ts +++ b/src/checkpoints/checkpoints.service.spec.ts @@ -159,6 +159,35 @@ describe('CheckpointsService', () => { }); }); + describe('getNextCheckpoint', () => { + it('should return the next Checkpoint details', async () => { + const mockAsset = new MockAsset(); + const mockNextCheckpoint = { + nextAt: new Date('10/14/1987'), + totalPending: new BigNumber(1), + schedules: [{ id: new BigNumber(1), nextAt: new Date('10/14/1987') }], + }; + mockAsset.checkpoints.schedules.getNextCheckpoint.mockResolvedValue(mockNextCheckpoint); + + mockAssetsService.findFungible.mockResolvedValue(mockAsset); + + const result = await service.getNextCheckpoint(assetId); + + expect(result).toEqual(mockNextCheckpoint); + }); + + it('should return null if the Asset has no active Schedules', async () => { + const mockAsset = new MockAsset(); + mockAsset.checkpoints.schedules.getNextCheckpoint.mockResolvedValue(null); + + mockAssetsService.findFungible.mockResolvedValue(mockAsset); + + const result = await service.getNextCheckpoint(assetId); + + expect(result).toBeNull(); + }); + }); + describe('findScheduleById', () => { let mockAsset: MockAsset; diff --git a/src/checkpoints/checkpoints.service.ts b/src/checkpoints/checkpoints.service.ts index 04c77b20..608b3c82 100644 --- a/src/checkpoints/checkpoints.service.ts +++ b/src/checkpoints/checkpoints.service.ts @@ -5,6 +5,7 @@ import { CheckpointSchedule, CheckpointWithData, IdentityBalance, + NextCheckpoints, ResultSet, ScheduleWithDetails, } from '@polymeshassociation/polymesh-sdk/types'; @@ -60,6 +61,12 @@ export class CheckpointsService { }); } + public async getNextCheckpoint(asset: string): Promise { + const fungibleAsset = await this.assetsService.findFungible(asset); + + return fungibleAsset.checkpoints.schedules.getNextCheckpoint(); + } + public async createByAsset( asset: string, signerDto: TransactionBaseDto diff --git a/src/checkpoints/models/next-checkpoint.model.ts b/src/checkpoints/models/next-checkpoint.model.ts new file mode 100644 index 00000000..e6a975a8 --- /dev/null +++ b/src/checkpoints/models/next-checkpoint.model.ts @@ -0,0 +1,37 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { Type } from 'class-transformer'; + +import { ScheduleNextCheckpointModel } from '~/checkpoints/models/schedule-next-checkpoint.model'; +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class NextCheckpointModel { + @ApiProperty({ + description: 'The closest upcoming Checkpoint creation date across all active Schedules', + type: 'string', + example: new Date('10/14/1987').toISOString(), + }) + readonly nextAt: Date; + + @ApiProperty({ + description: 'The total amount of pending Checkpoints across all active Schedules', + type: 'string', + example: '3', + }) + @FromBigNumber() + readonly totalPending: BigNumber; + + @ApiProperty({ + description: 'The next Checkpoint creation date for each active Schedule', + type: () => ScheduleNextCheckpointModel, + isArray: true, + }) + @Type(() => ScheduleNextCheckpointModel) + readonly schedules: ScheduleNextCheckpointModel[]; + + constructor(model: NextCheckpointModel) { + Object.assign(this, model); + } +} diff --git a/src/checkpoints/models/schedule-next-checkpoint.model.ts b/src/checkpoints/models/schedule-next-checkpoint.model.ts new file mode 100644 index 00000000..bc1a34a5 --- /dev/null +++ b/src/checkpoints/models/schedule-next-checkpoint.model.ts @@ -0,0 +1,27 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; + +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class ScheduleNextCheckpointModel { + @ApiProperty({ + description: 'The ID of the Checkpoint Schedule', + type: 'string', + example: '1', + }) + @FromBigNumber() + readonly id: BigNumber; + + @ApiProperty({ + description: 'The next Checkpoint creation date for this Schedule', + type: 'string', + example: new Date('10/14/1987').toISOString(), + }) + readonly nextAt: Date; + + constructor(model: ScheduleNextCheckpointModel) { + Object.assign(this, model); + } +} diff --git a/src/claims/claims.service.spec.ts b/src/claims/claims.service.spec.ts index 29084f45..179276d7 100644 --- a/src/claims/claims.service.spec.ts +++ b/src/claims/claims.service.spec.ts @@ -278,51 +278,6 @@ describe('ClaimsService', () => { }); }); - describe('findCddClaimsByDid', () => { - const date = new Date().toISOString(); - const mockCddClaims = [ - { - target: did, - issuer: did, - issuedAt: date, - expiry: date, - claim: { - type: 'Accredited', - scope: { - type: 'Identity', - value: did, - }, - }, - }, - ]; - - it('should return a list of CDD Claims for given DID', async () => { - mockPolymeshApi.claims.getCddClaims.mockResolvedValue(mockCddClaims); - - const result = await claimsService.findCddClaimsByDid(did); - - expect(result).toBe(mockCddClaims); - - expect(mockPolymeshApi.claims.getCddClaims).toHaveBeenCalledWith({ - target: did, - includeExpired: true, - }); - }); - - it('should return a list of CDD Claims for given DID without including expired claims', async () => { - mockPolymeshApi.claims.getCddClaims.mockResolvedValue(mockCddClaims); - - const result = await claimsService.findCddClaimsByDid(did, false); - - expect(result).toBe(mockCddClaims); - - expect(mockPolymeshApi.claims.getCddClaims).toHaveBeenCalledWith({ - target: did, - includeExpired: false, - }); - }); - }); - describe('findClaimScopesByDid', () => { it('should return claim scopes for the target identity', async () => { const mockClaims = [ diff --git a/src/claims/claims.service.ts b/src/claims/claims.service.ts index 6e9ba4c1..0f9ba558 100644 --- a/src/claims/claims.service.ts +++ b/src/claims/claims.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { AddClaimsParams, - CddClaim, ClaimData, ClaimScope, CustomClaimType, @@ -95,16 +94,6 @@ export class ClaimsService { }); } - public async findCddClaimsByDid( - target: string, - includeExpired = true - ): Promise[]> { - return await this.polymeshService.polymeshApi.claims.getCddClaims({ - target, - includeExpired, - }); - } - public async getCustomClaimTypeByName(name: string): Promise { return this.polymeshService.polymeshApi.claims.getCustomClaimTypeByName(name); } diff --git a/src/claims/dto/scope-claim-proof.dto.ts b/src/claims/dto/scope-claim-proof.dto.ts deleted file mode 100644 index 17ec59eb..00000000 --- a/src/claims/dto/scope-claim-proof.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ApiExtraModels, ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsString, ValidateIf } from 'class-validator'; - -import { ProofScopeIdCddIdMatchDto } from '~/claims/dto/proof-scope-id-cdd-id-match.dto'; -import { ApiPropertyOneOf } from '~/common/decorators/swagger'; - -@ApiExtraModels(ProofScopeIdCddIdMatchDto) -export class ScopeClaimProofDto { - @ApiProperty({ - description: 'The proof scope Id of the claim', - example: '0x0600000000000000000000000000000000000000000000000000000000000000', - }) - @IsString() - readonly proofScopeIdWellFormed: string; - - @ApiPropertyOneOf({ - description: 'The proof scope Id of the claim', - union: [ - { - type: 'string', - example: '0x0600000000000000000000000000000000000000000000000000000000000000', - }, - ProofScopeIdCddIdMatchDto, - ], - }) - @ValidateIf(({ proofScopeIdCddIdMatch }) => typeof proofScopeIdCddIdMatch !== 'string') - @Type(() => ProofScopeIdCddIdMatchDto) - readonly proofScopeIdCddIdMatch: string | ProofScopeIdCddIdMatchDto; -} diff --git a/src/claims/models/cdd-claim.model.ts b/src/claims/models/cdd-claim.model.ts deleted file mode 100644 index 0a7a616a..00000000 --- a/src/claims/models/cdd-claim.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* istanbul ignore file */ - -import { ApiProperty } from '@nestjs/swagger'; -import { ClaimType } from '@polymeshassociation/polymesh-sdk/types'; - -export class CddClaimModel { - @ApiProperty({ - type: 'string', - description: 'Claim type', - example: 'CustomerDueDiligence', - }) - readonly type: ClaimType.CustomerDueDiligence; - - @ApiProperty({ - type: 'string', - description: 'ID of the Claim', - example: '0x0600000000000000000000000000000000000000000000000000000000000000', - }) - readonly id: string; - - constructor(model: CddClaimModel) { - Object.assign(this, model); - } -} diff --git a/src/common/decorators/swagger.ts b/src/common/decorators/swagger.ts index 0c2fd5b6..900dedae 100644 --- a/src/common/decorators/swagger.ts +++ b/src/common/decorators/swagger.ts @@ -11,7 +11,6 @@ import { ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, getSchemaPath, - OmitType, } from '@nestjs/swagger'; import { ReferenceObject, @@ -20,7 +19,6 @@ import { import { PaginatedResultsModel } from '~/common/models/paginated-results.model'; import { ResultsModel } from '~/common/models/results.model'; -import { Class } from '~/common/types'; export const ApiArrayResponse = ( model: TModel, @@ -71,85 +69,6 @@ export const ApiArrayResponse = ( ); }; -export const ApiArrayResponseReplaceModelProperties = ( - Model: Type, - { - paginated, - example, - examples, - description, - }: { - paginated: boolean; - example?: unknown; - examples?: unknown[] | Record; - description?: string; - } = { - paginated: true, - }, - extendItems: Record -): ReturnType => { - const extraModels = []; - const items: SchemaObject = {}; - const keys = Object.keys(extendItems) as K[]; - - const obj = new Model() as unknown as Type; - const name = `${obj.constructor.name}-Omit-${keys.join('-')}`; - - const intermediary = { - [name]: class extends OmitType( - Model as unknown as Class, - keys as unknown as readonly never[] - ) {}, - }; - - items.allOf = [{ $ref: getSchemaPath(intermediary[name]) }]; - extraModels.push(intermediary[name]); - - for (const [key, value] of Object.entries(extendItems)) { - if (typeof value === 'function') { - extraModels.push(value); - items.allOf.push({ - type: 'object', - properties: { - [key]: { $ref: getSchemaPath(value) }, - }, - }); - } - - if (typeof value === 'string') { - items.allOf.push({ - type: 'object', - properties: { - [key]: { type: value }, - }, - }); - } - } - - return applyDecorators( - ApiOkResponse({ - description, - schema: { - allOf: [ - { $ref: getSchemaPath(paginated ? PaginatedResultsModel : ResultsModel) }, - { - properties: { - results: { - type: 'array', - items, - example, - examples, - description, - }, - }, - }, - ], - }, - }), - ApiExtraModels(PaginatedResultsModel, ResultsModel, ...extraModels) - ); -}; - type ApiPropertyOneOfOptions = Omit< ApiPropertyOptions, 'oneOf' | 'type' | 'additionalProperties' | 'selfRequired' | 'properties' | 'required' diff --git a/src/common/utils/feature-flags.spec.ts b/src/common/utils/feature-flags.spec.ts new file mode 100644 index 00000000..68b7c308 --- /dev/null +++ b/src/common/utils/feature-flags.spec.ts @@ -0,0 +1,86 @@ +import { + isAuthManagementEnabled, + isDeveloperUtilsEnabled, + isSignerManagementEnabled, + parseBooleanEnv, +} from '~/common/utils/feature-flags'; + +describe('feature flags', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.AUTH_STRATEGY; + delete process.env.DEVELOPER_UTILS; + delete process.env.AUTH_MANAGEMENT_ENABLED; + delete process.env.SIGNER_MANAGEMENT_ENABLED; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('parseBooleanEnv', () => { + it('should return undefined for unset or blank values', () => { + expect(parseBooleanEnv(undefined)).toBeUndefined(); + expect(parseBooleanEnv('')).toBeUndefined(); + expect(parseBooleanEnv(' ')).toBeUndefined(); + }); + + it('should only treat "true" as true', () => { + expect(parseBooleanEnv('true')).toBe(true); + expect(parseBooleanEnv(' TRUE ')).toBe(true); + expect(parseBooleanEnv('false')).toBe(false); + expect(parseBooleanEnv('FALSE')).toBe(false); + expect(parseBooleanEnv('1')).toBe(false); + }); + }); + + describe('isDeveloperUtilsEnabled', () => { + it('should be disabled when unset', () => { + expect(isDeveloperUtilsEnabled()).toBe(false); + }); + + it('should be disabled when set to "false"', () => { + process.env.DEVELOPER_UTILS = 'false'; + expect(isDeveloperUtilsEnabled()).toBe(false); + }); + + it('should be enabled when set to "true"', () => { + process.env.DEVELOPER_UTILS = 'true'; + expect(isDeveloperUtilsEnabled()).toBe(true); + }); + }); + + describe.each([ + ['isAuthManagementEnabled', isAuthManagementEnabled, 'AUTH_MANAGEMENT_ENABLED'], + ['isSignerManagementEnabled', isSignerManagementEnabled, 'SIGNER_MANAGEMENT_ENABLED'], + ])('%s', (_, isEnabled, envKey) => { + it('should be disabled by default when no auth strategy is set', () => { + expect(isEnabled()).toBe(false); + }); + + it('should be disabled by default when the open strategy is configured', () => { + process.env.AUTH_STRATEGY = 'open'; + expect(isEnabled()).toBe(false); + + process.env.AUTH_STRATEGY = 'apiKey, open'; + expect(isEnabled()).toBe(false); + }); + + it('should be enabled by default when only the apiKey strategy is configured', () => { + process.env.AUTH_STRATEGY = 'apiKey'; + expect(isEnabled()).toBe(true); + }); + + it('should respect an explicit value', () => { + process.env.AUTH_STRATEGY = 'open'; + process.env[envKey] = 'true'; + expect(isEnabled()).toBe(true); + + process.env.AUTH_STRATEGY = 'apiKey'; + process.env[envKey] = 'false'; + expect(isEnabled()).toBe(false); + }); + }); +}); diff --git a/src/common/utils/feature-flags.ts b/src/common/utils/feature-flags.ts new file mode 100644 index 00000000..237cccd8 --- /dev/null +++ b/src/common/utils/feature-flags.ts @@ -0,0 +1,49 @@ +import { parseAuthStrategyConfig } from '~/auth/auth.utils'; +import { AuthStrategy } from '~/auth/strategies/strategies.consts'; + +/** + * Feature flags that decide which controllers get registered. These are read from `process.env` while the + * module graph is being built, so they must only be called after `ConfigModule.forRoot` has run + * + * @note `ConfigModule` writes validated values back to `process.env` as strings, so a `Joi.bool()` default of + * `false` ends up as the string `"false"`, which is truthy. Always compare against `"true"` explicitly + */ +export const parseBooleanEnv = (value: string | undefined): boolean | undefined => { + if (value === undefined || value.trim() === '') { + return undefined; + } + + return value.trim().toLowerCase() === 'true'; +}; + +const isOpenAuthConfigured = (): boolean => { + const { AUTH_STRATEGY } = process.env; + + if (!AUTH_STRATEGY) { + return true; + } + + return parseAuthStrategyConfig(AUTH_STRATEGY).includes(AuthStrategy.Open); +}; + +/** + * Whether the `/developer-testing` endpoints should be exposed. Disabled unless `DEVELOPER_UTILS=true` + */ +export const isDeveloperUtilsEnabled = (): boolean => + parseBooleanEnv(process.env.DEVELOPER_UTILS) ?? false; + +/** + * Whether REST API users and API keys can be managed over HTTP (`/users/create`, `/auth/api-key/*`) + * + * Defaults to disabled when the `open` auth strategy is configured, since any caller could otherwise use them + */ +export const isAuthManagementEnabled = (): boolean => + parseBooleanEnv(process.env.AUTH_MANAGEMENT_ENABLED) ?? !isOpenAuthConfigured(); + +/** + * Whether signing keys can be added over HTTP (`POST /signer`) + * + * Defaults to disabled when the `open` auth strategy is configured, since any caller could otherwise use them + */ +export const isSignerManagementEnabled = (): boolean => + parseBooleanEnv(process.env.SIGNER_MANAGEMENT_ENABLED) ?? !isOpenAuthConfigured(); diff --git a/src/corporate-actions/corporate-actions.controller.spec.ts b/src/corporate-actions/corporate-actions.controller.spec.ts index a31cb2b2..4af8cafd 100644 --- a/src/corporate-actions/corporate-actions.controller.spec.ts +++ b/src/corporate-actions/corporate-actions.controller.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { AssetDocumentDto } from '~/assets/dto/asset-document.dto'; +import { AssetDocumentWithIdModel } from '~/assets/models/asset-document-with-id.model'; import { PaginatedResultsModel } from '~/common/models/paginated-results.model'; import { ResultsModel } from '~/common/models/results.model'; import { CorporateActionsController } from '~/corporate-actions/corporate-actions.controller'; @@ -36,6 +37,7 @@ describe('CorporateActionsController', () => { payDividends: jest.fn(), claimDividends: jest.fn(), linkDocuments: jest.fn(), + getDocuments: jest.fn(), reclaimRemainingFunds: jest.fn(), modifyCheckpoint: jest.fn(), findUnclaimedDistributionsByAsset: jest.fn(), @@ -213,6 +215,19 @@ describe('CorporateActionsController', () => { }); }); + describe('getDocuments', () => { + it('should return the documents linked to the Corporate Action', async () => { + const mockDocuments = [{ name: 'DOC_NAME', uri: 'DOC_URI', id: new BigNumber(1) }]; + mockCorporateActionsService.getDocuments.mockResolvedValue(mockDocuments); + + const result = await controller.getDocuments({ asset: assetId, id: new BigNumber(1) }); + + expect(result).toEqual({ + results: mockDocuments.map(document => new AssetDocumentWithIdModel(document)), + }); + }); + }); + describe('claimDividends', () => { it('should call the service and return the transaction details', async () => { await testControllerTxResult( diff --git a/src/corporate-actions/corporate-actions.controller.ts b/src/corporate-actions/corporate-actions.controller.ts index 23959e9b..83555a50 100644 --- a/src/corporate-actions/corporate-actions.controller.ts +++ b/src/corporate-actions/corporate-actions.controller.ts @@ -12,6 +12,7 @@ import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { DividendDistribution } from '@polymeshassociation/polymesh-sdk/types'; import { AssetParamsDto } from '~/assets/dto/asset-params.dto'; +import { AssetDocumentWithIdModel } from '~/assets/models/asset-document-with-id.model'; import { ApiArrayResponse, ApiTransactionResponse } from '~/common/decorators/'; import { IsAsset } from '~/common/decorators/validation'; import { IdParamsDto } from '~/common/dto/id-params.dto'; @@ -349,6 +350,37 @@ export class CorporateActionsController { return handleServiceResult(result); } + @ApiOperation({ + summary: 'Get documents linked to a Corporate Action', + description: 'This endpoint retrieves the documents linked to a Corporate Action', + }) + @ApiParam({ + name: 'asset', + description: 'The Asset (Ticker/Asset ID) of the Corporate Action', + type: 'string', + example: '3616b82e-8e10-80ae-dc95-2ea28b9db8b3', + }) + @ApiParam({ + name: 'id', + description: 'The ID of the Corporate Action', + type: 'string', + example: '123', + }) + @ApiArrayResponse(AssetDocumentWithIdModel, { + description: 'List of documents linked to the Corporate Action', + paginated: false, + }) + @Get(':id/documents') + public async getDocuments( + @Param() { asset, id }: DividendDistributionParamsDto + ): Promise> { + const documents = await this.corporateActionsService.getDocuments(asset, id); + + const results = documents.map(document => new AssetDocumentWithIdModel(document)); + + return new ResultsModel({ results }); + } + @ApiTags('dividend-distributions') @ApiOperation({ summary: 'Claim dividend payment for a Dividend Distribution', diff --git a/src/corporate-actions/corporate-actions.service.spec.ts b/src/corporate-actions/corporate-actions.service.spec.ts index c50460e0..141ace0f 100644 --- a/src/corporate-actions/corporate-actions.service.spec.ts +++ b/src/corporate-actions/corporate-actions.service.spec.ts @@ -304,6 +304,22 @@ describe('CorporateActionsService', () => { }); }); + describe('getDocuments', () => { + it('should return the documents linked to the Corporate Action', async () => { + const mockDistributionWithDetails = new MockDistributionWithDetails(); + const mockDocuments = [{ name: 'DOC_NAME', uri: 'DOC_URI', id: new BigNumber(1) }]; + mockDistributionWithDetails.distribution.getDocuments.mockResolvedValue(mockDocuments); + + const findDistributionSpy = jest.spyOn(service, 'findDistribution'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + findDistributionSpy.mockResolvedValue(mockDistributionWithDetails as any); + + const result = await service.getDocuments(assetId, new BigNumber(1)); + + expect(result).toEqual(mockDocuments); + }); + }); + describe('claimDividends', () => { describe('otherwise', () => { it('should return the transaction details', async () => { diff --git a/src/corporate-actions/corporate-actions.service.ts b/src/corporate-actions/corporate-actions.service.ts index ae9f0e02..c35629a8 100644 --- a/src/corporate-actions/corporate-actions.service.ts +++ b/src/corporate-actions/corporate-actions.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { + AssetDocumentWithId, CorporateActionDefaultConfig, DistributionPayment, DistributionWithDetails, @@ -122,6 +123,12 @@ export class CorporateActionsService { return this.transactionService.submit(distribution.linkDocuments, params, options); } + public async getDocuments(asset: string, id: BigNumber): Promise { + const { distribution } = await this.findDistribution(asset, id); + + return distribution.getDocuments(); + } + public async claimDividends( asset: string, id: BigNumber, diff --git a/src/corporate-actions/mocks/dividend-distribution.mock.ts b/src/corporate-actions/mocks/dividend-distribution.mock.ts index 47d75648..1408c787 100644 --- a/src/corporate-actions/mocks/dividend-distribution.mock.ts +++ b/src/corporate-actions/mocks/dividend-distribution.mock.ts @@ -20,6 +20,7 @@ export class MockDistribution extends MockCorporateActionDefaultConfig { public pay = jest.fn(); public claim = jest.fn(); public linkDocuments = jest.fn(); + public getDocuments = jest.fn(); public reclaimFunds = jest.fn(); public modifyCheckpoint = jest.fn(); } diff --git a/src/developer-testing/developer-testing.module.ts b/src/developer-testing/developer-testing.module.ts index 20978473..4889d16f 100644 --- a/src/developer-testing/developer-testing.module.ts +++ b/src/developer-testing/developer-testing.module.ts @@ -4,6 +4,7 @@ import { DynamicModule, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AccountsModule } from '~/accounts/accounts.module'; +import { isDeveloperUtilsEnabled } from '~/common/utils/feature-flags'; import { DeveloperTestingController } from '~/developer-testing/developer-testing.controller'; import { DeveloperTestingService } from '~/developer-testing/developer-testing.service'; import { PolymeshModule } from '~/polymesh/polymesh.module'; @@ -13,13 +14,7 @@ import { TransactionsModule } from '~/transactions/transactions.module'; @Module({}) export class DeveloperTestingModule { static register(): DynamicModule { - const controllers = []; - - const DEVELOPER_UTILS: boolean = JSON.parse(`${!!process.env.DEVELOPER_UTILS}`); - - if (DEVELOPER_UTILS) { - controllers.push(DeveloperTestingController); - } + const controllers = isDeveloperUtilsEnabled() ? [DeveloperTestingController] : []; return { module: DeveloperTestingModule, diff --git a/src/developer-testing/developer-testing.service.spec.ts b/src/developer-testing/developer-testing.service.spec.ts index ba42c4b8..ef210d98 100644 --- a/src/developer-testing/developer-testing.service.spec.ts +++ b/src/developer-testing/developer-testing.service.spec.ts @@ -61,7 +61,7 @@ describe('DeveloperTestingService', () => { polymeshService.execTransaction = jest.fn(); mockPolymeshApi.network.getSs58Format.mockReturnValue(new BigNumber(42)); - Object.assign(mockPolymeshApi, { context: { isV7: true } }); + mockAccountsService.getAccountBalance.mockResolvedValue({ free: new BigNumber(0) }); }); afterEach(async () => { @@ -97,6 +97,12 @@ describe('DeveloperTestingService', () => { const identities = await service.createTestAdmins(params); expect(identities).toEqual([{ did: 'fakeId' }, { did: 'fakeSecondaryId' }]); + expect(mockPolymeshApi._polkadotApi.tx.didRegistrars.addMember).toHaveBeenCalledWith( + 'fakeId' + ); + expect(mockPolymeshApi._polkadotApi.tx.didRegistrars.addMember).toHaveBeenCalledWith( + 'fakeSecondaryId' + ); }); }); @@ -149,7 +155,9 @@ describe('DeveloperTestingService', () => { expect(polymeshService.execTransaction).toHaveBeenCalledWith( expect.objectContaining({ address: defaultAdminAddress }), expect.anything(), - expect.anything() + address, + expect.anything(), + null ); }); }); diff --git a/src/developer-testing/developer-testing.service.ts b/src/developer-testing/developer-testing.service.ts index 15e74ab7..f6e889cf 100644 --- a/src/developer-testing/developer-testing.service.ts +++ b/src/developer-testing/developer-testing.service.ts @@ -23,10 +23,6 @@ import { TransactionsService } from '~/transactions/transactions.service'; const unitsPerPolyx = 1000000; -type PolymeshWithContext = PolymeshService['polymeshApi'] & { - context: { isV7: boolean }; -}; - @Injectable() export class DeveloperTestingService { private _sudoPair: KeyringPair; @@ -46,25 +42,30 @@ export class DeveloperTestingService { public async createTestAdmins({ accounts }: CreateTestAdminsDto): Promise { const identities = await this.createTestAccounts({ accounts }); - if (this.isChainV7()) { - await this.createCddProvidersBatch(identities); - } + await this.createDidRegistrarsBatch(identities); return identities; } /** - * @note the `signer` must be a CDD provider and have sufficient POLYX to cover the `initialPolyx` + * @note the `signer` must have sufficient POLYX to cover the `initialPolyx` */ public async createTestAccounts({ accounts, signer, }: CreateTestAccountsDto): Promise { - if (this.isChainV7()) { - return this.createTestAccountsV7({ accounts, signer }); + const accountsWithoutIdentity = await this.findAccountsWithoutIdentity(accounts); + + if (accountsWithoutIdentity.length) { + await this.prefundNewAccounts(accountsWithoutIdentity, signer); } - return this.createTestAccountsV8({ accounts, signer }); + await this.selfRegisterNewAccounts(accounts); + await this.fundAccountsWithInitialPolyx(accounts, signer); + + const madeAccounts = await this.fetchAccountForAccountParams(accounts); + + return this.fetchAccountsIdentities(madeAccounts); } /** @@ -83,7 +84,7 @@ export class DeveloperTestingService { } = this.polymeshService.polymeshApi; if (!signer) { - throw new AppInternalError('A signer is required to prefund accounts on chain v8'); + throw new AppInternalError('A signer is required to prefund accounts'); } const fundingAddress = await this.signingService.getAddressByHandle(signer); @@ -107,66 +108,6 @@ export class DeveloperTestingService { } } - private async createTestAccountsV7({ - accounts, - signer, - }: CreateTestAccountsDto): Promise { - const { - _polkadotApi: { - tx: { utility, balances, identity }, - }, - } = this.polymeshService.polymeshApi; - - const signerAddress = signer - ? await this.signingService.getAddressByHandle(signer) - : this.sudoPair; - - // Create a DID to attach claim too - const createDidCalls = accounts.map(({ address }) => identity.cddRegisterDid(address, [])); - await this.polymeshService.execTransaction(signerAddress, utility.batch, createDidCalls); - - // Fetch the Account and Identity that was made - const madeAccounts = await this.fetchAccountForAccountParams(accounts); - const identities = await this.fetchAccountsIdentities(madeAccounts); - - // Now create a CDD claim for each Identity - const createCddCalls = identities.map(({ did }) => - identity.addClaim(did, { CustomerDueDiligence: did }, null) - ); - - // and provide POLYX for those that are supposed to get some - const initialPolyxCalls = accounts - .filter(({ initialPolyx }) => initialPolyx.gt(0)) - .map(({ address, initialPolyx }) => - balances.transferWithMemo(address, initialPolyx.toNumber() * unitsPerPolyx, null) - ); - - await this.polymeshService.execTransaction(signerAddress, utility.batchAll, [ - ...createCddCalls, - ...initialPolyxCalls, - ]); - - return identities; - } - - private async createTestAccountsV8({ - accounts, - signer, - }: CreateTestAccountsDto): Promise { - const accountsWithoutIdentity = await this.findAccountsWithoutIdentity(accounts); - - if (accountsWithoutIdentity.length) { - await this.prefundNewAccountsForV8(accountsWithoutIdentity, signer); - } - - await this.selfRegisterNewAccountsForV8(accounts); - await this.fundAccountsWithInitialPolyxForV8(accounts, signer); - - const madeAccounts = await this.fetchAccountForAccountParams(accounts); - - return this.fetchAccountsIdentities(madeAccounts); - } - private async findAccountsWithoutIdentity( accounts: CreateTestAccountsDto['accounts'] ): Promise { @@ -184,7 +125,7 @@ export class DeveloperTestingService { return accountsWithoutIdentity; } - private async prefundNewAccountsForV8( + private async prefundNewAccounts( accountsWithoutIdentity: CreateTestAccountsDto['accounts'], signer?: string ): Promise { @@ -224,7 +165,7 @@ export class DeveloperTestingService { } } - private async selfRegisterNewAccountsForV8( + private async selfRegisterNewAccounts( accounts: CreateTestAccountsDto['accounts'] ): Promise { const { selfRegisterDid } = this.polymeshService.polymeshApi.identities; @@ -258,7 +199,7 @@ export class DeveloperTestingService { } } - private async fundAccountsWithInitialPolyxForV8( + private async fundAccountsWithInitialPolyx( accounts: CreateTestAccountsDto['accounts'], signer?: string ): Promise { @@ -298,30 +239,26 @@ export class DeveloperTestingService { } } - private isChainV7(): boolean { - return (this.polymeshService.polymeshApi as PolymeshWithContext).context.isV7; - } - /** * @note relies on having a sudo account configured */ - private async createCddProvidersBatch(identities: Identity[]): Promise { + private async createDidRegistrarsBatch(identities: Identity[]): Promise { const { polymeshService: { polymeshApi: { _polkadotApi: { - tx: { cddServiceProviders, sudo, utility }, + tx: { didRegistrars, sudo, utility }, }, }, }, sudoPair, } = this; - const cddCalls = identities.map(({ did }) => { - return cddServiceProviders.addMember(did); + const registrarCalls = identities.map(({ did }) => { + return didRegistrars.addMember(did); }); - const batchTx = utility.batchAll(cddCalls); + const batchTx = utility.batchAll(registrarCalls); await this.polymeshService.execTransaction(sudoPair, sudo.sudo, batchTx); } diff --git a/src/identities/dto/add-secondary-account-params.dto.spec.ts b/src/identities/dto/add-secondary-account-params.dto.spec.ts index b14064a5..8ac2df69 100644 --- a/src/identities/dto/add-secondary-account-params.dto.spec.ts +++ b/src/identities/dto/add-secondary-account-params.dto.spec.ts @@ -271,7 +271,7 @@ describe('addSecondaryAccountParamsDto', () => { signer, }, [ - 'permissions.each value in transactionGroups must be one of the following values: AdvancedAssetManagement, AssetManagement, AssetDocumentManagement, AssetMetadataManagement, AssetRegistration, AuthorizationManagement, CapitalDistribution, CddRegistration, CheckpointManagement, ClaimsManagement, ComplianceManagement, CorporateActionsManagement, CorporateBallotManagement, CorporateVoting, ExternalAgentManagement, ExternalAgentParticipation, Issuance, MultiSigManagement, PortfolioManagement, RelayerManagement, Redemption, SettlementManagement, StoManagement, TrustedClaimIssuersManagement', + 'permissions.each value in transactionGroups must be one of the following values: AdvancedAssetManagement, AssetManagement, AssetDocumentManagement, AssetMetadataManagement, AssetRegistration, AuthorizationManagement, CapitalDistribution, CddRegistration, DidRegistration, InstructionMediation, CheckpointManagement, ClaimsManagement, ComplianceManagement, CorporateActionsManagement, CorporateBallotManagement, CorporateVoting, ExternalAgentManagement, ExternalAgentParticipation, Issuance, MultiSigManagement, PortfolioManagement, Redemption, SettlementManagement, StoManagement, TrustedClaimIssuersManagement', ], ], [ diff --git a/src/identities/dto/register-did.dto.ts b/src/identities/dto/register-did.dto.ts new file mode 100644 index 00000000..a5ed0a15 --- /dev/null +++ b/src/identities/dto/register-did.dto.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; + +export class RegisterDidDto extends TransactionBaseDto { + @ApiProperty({ + description: 'Account address for which to create an Identity', + example: '5grwXxxXxxXxxXxxXxxXxxXxxXxxXxxXxxXxxXxxXxxXxxXx', + }) + @IsString() + readonly targetAccount: string; +} diff --git a/src/identities/dto/register-identity.dto.ts b/src/identities/dto/register-identity.dto.ts index 4619ad7b..e9414730 100644 --- a/src/identities/dto/register-identity.dto.ts +++ b/src/identities/dto/register-identity.dto.ts @@ -25,20 +25,23 @@ export class RegisterIdentityDto extends TransactionBaseDto { @Type(() => PermissionedAccountDto) readonly secondaryAccounts?: PermissionedAccountDto[]; - @ApiProperty({ + @ApiPropertyOptional({ description: - 'Issue a CDD claim for the created DID, completing the onboarding process for the Account. Deprecated on chain v8', + 'Issue a CDD claim for the created DID. Deprecated: as of chain v8 this no longer has any on-chain effect', type: 'boolean', example: false, deprecated: true, }) + @IsOptional() @IsBoolean() - readonly createCdd: boolean; + readonly createCdd?: boolean; @ApiPropertyOptional({ - description: 'Date at which the Identity will expire (to be used together with createCdd)', + description: + 'Date at which the Identity will expire. Deprecated: as of chain v8 this no longer has any on-chain effect', example: new Date(new Date().getTime() + +365 * 24 * 60 * 60 * 1000).toISOString(), type: 'string', + deprecated: true, }) @IsOptional() @IsDate() diff --git a/src/identities/dto/set-mandatory-receiver-affirmation.dto.ts b/src/identities/dto/set-mandatory-receiver-affirmation.dto.ts index ecc1ddac..b2968834 100644 --- a/src/identities/dto/set-mandatory-receiver-affirmation.dto.ts +++ b/src/identities/dto/set-mandatory-receiver-affirmation.dto.ts @@ -9,7 +9,7 @@ import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; export class SetMandatoryReceiverAffirmationDto extends TransactionBaseDto { @ApiProperty({ description: - 'Whether receiver affirmation is required for incoming settlement transfers. Automatic affirms by default on chain v8', + 'Whether receiver affirmation is required for incoming settlement transfers. Automatic affirms by default', enum: ReceiverAffirmationRequirement, example: ReceiverAffirmationRequirement.Required, }) diff --git a/src/identities/identities.controller.spec.ts b/src/identities/identities.controller.spec.ts index 29d224b6..57636989 100644 --- a/src/identities/identities.controller.spec.ts +++ b/src/identities/identities.controller.spec.ts @@ -4,7 +4,6 @@ import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { Asset, AuthorizationType, - CddClaim, ClaimData, ClaimScope, ClaimType, @@ -671,39 +670,6 @@ describe('IdentitiesController', () => { }); }); - describe('getCddClaims', () => { - const date = new Date().toISOString(); - const mockCddClaims = [ - { - target: did, - issuer: did, - issuedAt: date, - expiry: date, - claim: { - type: 'Accredited', - scope: { - type: 'Identity', - value: did, - }, - }, - }, - ] as unknown as ClaimData[]; - - it('should call the service and return list of CDD Claims', async () => { - mockClaimsService.findCddClaimsByDid.mockResolvedValue(mockCddClaims); - const result = await controller.getCddClaims({ did }, { includeExpired: false }); - expect(result).toEqual(new ResultsModel({ results: mockCddClaims })); - expect(mockClaimsService.findCddClaimsByDid).toHaveBeenCalledWith(did, false); - }); - - it('should call the service and return list of CDD Claims including expired claims', async () => { - mockClaimsService.findCddClaimsByDid.mockResolvedValue(mockCddClaims); - const result = await controller.getCddClaims({ did }, { includeExpired: true }); - expect(result).toEqual(new ResultsModel({ results: mockCddClaims })); - expect(mockClaimsService.findCddClaimsByDid).toHaveBeenCalledWith(did, true); - }); - }); - describe('selfRegisterDid', () => { it('should return transaction details for self-registration', async () => { const identity = new MockIdentity(); @@ -768,36 +734,41 @@ describe('IdentitiesController', () => { }); }); + const setupRegisteredIdentity = (): { + mockData: typeof txResult & { result: MockIdentity }; + identityData: IdentityModel; + } => { + const identity = new MockIdentity(); + const address = 'address'; + identity.getPrimaryAccount.mockResolvedValue({ + account: { address }, + permissions: [], + }); + identity.areSecondaryAccountsFrozen.mockResolvedValue(false); + identity.getSecondaryAccounts.mockResolvedValue({ data: [] }); + + const identityData = new IdentityModel({ + did, + primaryAccount: new PermissionedAccountModel({ + account: new AccountModel({ address }), + permissions: new PermissionsModel({ + assets: null, + portfolios: null, + transactionGroups: [], + transactions: null, + }), + }), + secondaryAccounts: [], + secondaryAccountsFrozen: false, + }); + + return { mockData: { ...txResult, result: identity }, identityData }; + }; + describe('registerIdentity', () => { it('should return the transaction details on adding registering an Identity', async () => { - const identity = new MockIdentity(); - const address = 'address'; - identity.getPrimaryAccount.mockResolvedValue({ - account: { address }, - permissions: [], - }); - identity.areSecondaryAccountsFrozen.mockResolvedValue(false); - identity.getSecondaryAccounts.mockResolvedValue({ data: [] }); + const { mockData, identityData } = setupRegisteredIdentity(); - const identityData = new IdentityModel({ - did, - primaryAccount: new PermissionedAccountModel({ - account: new AccountModel({ address }), - permissions: new PermissionsModel({ - assets: null, - portfolios: null, - transactionGroups: [], - transactions: null, - }), - }), - secondaryAccounts: [], - secondaryAccountsFrozen: false, - }); - - const mockData = { - ...txResult, - result: identity, - }; mockIdentitiesService.registerDid.mockResolvedValue(mockData); const data: RegisterIdentityDto = { @@ -815,6 +786,27 @@ describe('IdentitiesController', () => { }); }); + describe('registerDid', () => { + it('should return the transaction details on registering a DID', async () => { + const { mockData, identityData } = setupRegisteredIdentity(); + + mockIdentitiesService.registerDidAsRegistrar.mockResolvedValue(mockData); + + const data = { + signer: 'Ox60', + targetAccount: 'address', + }; + + const result = await controller.registerDid(data); + + expect(result).toEqual({ + ...processedTxResult, + identity: identityData, + }); + expect(mockIdentitiesService.registerDidAsRegistrar).toHaveBeenCalledWith(data); + }); + }); + describe('getGroupedInstructions', () => { it("should return the Identity's Instructions", async () => { const expectedInstructionIds = [new BigNumber(1), new BigNumber(2), new BigNumber(3)]; diff --git a/src/identities/identities.controller.ts b/src/identities/identities.controller.ts index e30709a5..5815bc8e 100644 --- a/src/identities/identities.controller.ts +++ b/src/identities/identities.controller.ts @@ -35,12 +35,10 @@ import { CreatedAuthorizationRequestModel } from '~/authorizations/models/create import { PendingAuthorizationsModel } from '~/authorizations/models/pending-authorizations.model'; import { ClaimsService } from '~/claims/claims.service'; import { ClaimsFilterDto } from '~/claims/dto/claims-filter.dto'; -import { CddClaimModel } from '~/claims/models/cdd-claim.model'; import { ClaimModel } from '~/claims/models/claim.model'; import { ClaimScopeModel } from '~/claims/models/claim-scope.model'; import { ApiArrayResponse, - ApiArrayResponseReplaceModelProperties, ApiTransactionFailedResponse, ApiTransactionResponse, } from '~/common/decorators/'; @@ -54,6 +52,7 @@ import { handleServiceResult, TransactionResponseModel } from '~/common/utils'; import { createDividendDistributionDetailsModel } from '~/corporate-actions/corporate-actions.util'; import { DividendDistributionDetailsModel } from '~/corporate-actions/models/dividend-distribution-details.model'; import { AddSecondaryAccountParamsDto } from '~/identities/dto/add-secondary-account-params.dto'; +import { RegisterDidDto } from '~/identities/dto/register-did.dto'; import { RegisterIdentityDto } from '~/identities/dto/register-identity.dto'; import { RotatePrimaryKeyParamsDto } from '~/identities/dto/rotate-primary-key-params.dto'; import { SetMandatoryReceiverAffirmationDto } from '~/identities/dto/set-mandatory-receiver-affirmation.dto'; @@ -87,8 +86,7 @@ export class IdentitiesController { @Post('self-register') @ApiOperation({ summary: 'Self-register Identity', - description: - 'Registers a new DID for the signing Account on chain v8 without a CDD provider. Not supported on chain v7', + description: 'Registers a new DID for the signing Account without requiring a DID Registrar', }) @ApiTransactionResponse({ description: 'Newly created Identity along with transaction details', @@ -106,7 +104,7 @@ export class IdentitiesController { @ApiOperation({ summary: 'Register Identity', description: - 'This endpoint allows registering a new Identity. The transaction signer must be a CDD provider (Did Registrar on chain v8). This will create Authorization Requests which have to be accepted by any secondary accounts if they were specified.', + 'This endpoint allows registering a new Identity. The transaction signer must be a DID Registrar. This will create Authorization Requests which have to be accepted by any secondary accounts if they were specified.', }) @ApiTransactionResponse({ description: 'Newly created Authorization Request along with transaction details', @@ -124,6 +122,23 @@ export class IdentitiesController { return handleServiceResult(serviceResult, createIdentityResolver); } + @Post('register-did') + @ApiOperation({ + summary: 'Register DID', + description: + "Registers a new DID for the target Account. The transaction signer must be an active DID Registrar. Unlike 'Register Identity', this does not support secondary keys or CDD claims", + }) + @ApiTransactionResponse({ + description: 'Newly created Identity along with transaction details', + type: CreatedIdentityModel, + }) + async registerDid(@Body() registerDidDto: RegisterDidDto): Promise { + this.logger.debug('Registering new DID'); + const serviceResult = await this.identitiesService.registerDidAsRegistrar(registerDidDto); + + return handleServiceResult(serviceResult, createIdentityResolver); + } + @Get(':did') @ApiOperation({ summary: 'Get Identity details', @@ -549,46 +564,6 @@ export class IdentitiesController { return new ResultsModel({ results }); } - @ApiTags('claims') - @ApiOperation({ - summary: 'Fetch all CDD claims for an Identity', - description: - 'This endpoint will fetch the list of CDD claims for a target DID. Deprecated on chain v8 where CDD claims are no longer supported', - deprecated: true, - }) - @ApiParam({ - name: 'did', - description: 'The DID of the Identity whose CDD claims are to be fetched', - type: 'string', - required: true, - example: '0x0600000000000000000000000000000000000000000000000000000000000000', - }) - @ApiQuery({ - name: 'includeExpired', - description: 'Indicates whether to include expired CDD claims or not. Defaults to true', - type: 'boolean', - required: false, - }) - @ApiArrayResponseReplaceModelProperties( - ClaimModel, - { - description: 'List of CDD claims for the target DID', - paginated: false, - }, - { claim: CddClaimModel } - ) - @Get(':did/cdd-claims') - public async getCddClaims( - @Param() { did }: DidDto, - @Query() { includeExpired }: IncludeExpiredFilterDto - ): Promise>> { - const cddClaims = await this.claimsService.findCddClaimsByDid(did, includeExpired); - - const results = cddClaims.map(claim => new ClaimModel(claim)); - - return { results }; - } - @ApiTags('claims') @ApiOperation({ summary: 'Fetch all claim scopes for an Identity', @@ -641,7 +616,7 @@ export class IdentitiesController { @ApiOperation({ summary: 'Set mandatory receiver affirmation', description: - 'Enable or disable mandatory receiver affirmation for incoming settlement transfers on chain v8', + 'Enable or disable mandatory receiver affirmation for incoming settlement transfers', }) @ApiParam({ name: 'did', diff --git a/src/identities/identities.module.ts b/src/identities/identities.module.ts index acc9af33..fa1e8d9e 100644 --- a/src/identities/identities.module.ts +++ b/src/identities/identities.module.ts @@ -6,7 +6,6 @@ import { AccountsModule } from '~/accounts/accounts.module'; import { AssetsModule } from '~/assets/assets.module'; import { AuthorizationsModule } from '~/authorizations/authorizations.module'; import { ClaimsModule } from '~/claims/claims.module'; -import { DeveloperTestingModule } from '~/developer-testing/developer-testing.module'; import { IdentitiesController } from '~/identities/identities.controller'; import { IdentitiesService } from '~/identities/identities.service'; import { LoggerModule } from '~/logger/logger.module'; @@ -25,7 +24,6 @@ import { TransactionsModule } from '~/transactions/transactions.module'; forwardRef(() => SettlementsModule), forwardRef(() => AuthorizationsModule), forwardRef(() => PortfoliosModule), - DeveloperTestingModule.register(), AccountsModule, ClaimsModule, TickerReservationsModule, diff --git a/src/identities/identities.service.spec.ts b/src/identities/identities.service.spec.ts index b2699ea3..1fab9853 100644 --- a/src/identities/identities.service.spec.ts +++ b/src/identities/identities.service.spec.ts @@ -374,6 +374,38 @@ describe('IdentitiesService', () => { }); }); + describe('registerDidAsRegistrar', () => { + it('should return the transaction details', async () => { + const body = { + signer, + targetAccount: 'address', + }; + + await testServiceTransactionResult( + service.registerDidAsRegistrar.bind(service), + mockTransactionsService, + body, + TxTags.identity.RegisterDid + ); + }); + + it('should submit only the targetAccount', async () => { + const mockTransaction = setupMockTransaction( + mockTransactionsService, + TxTags.identity.RegisterDid + ); + + const body = { + signer, + targetAccount: 'address', + }; + + const result = await service.registerDidAsRegistrar(body); + expectTransactionResult(result, mockTransaction); + expectLastSubmitCall(mockTransactionsService, { targetAccount: 'address' }); + }); + }); + describe('selfRegisterDid', () => { it('should submit selfRegisterDid and return transaction details', async () => { const transaction = { diff --git a/src/identities/identities.service.ts b/src/identities/identities.service.ts index 7ee2e444..9b1d14c8 100644 --- a/src/identities/identities.service.ts +++ b/src/identities/identities.service.ts @@ -14,6 +14,7 @@ import { import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; import { extractTxOptions, ServiceReturn } from '~/common/utils'; import { AddSecondaryAccountParamsDto } from '~/identities/dto/add-secondary-account-params.dto'; +import { RegisterDidDto } from '~/identities/dto/register-did.dto'; import { RegisterIdentityDto } from '~/identities/dto/register-identity.dto'; import { RotatePrimaryKeyParamsDto } from '~/identities/dto/rotate-primary-key-params.dto'; import { SetMandatoryReceiverAffirmationDto } from '~/identities/dto/set-mandatory-receiver-affirmation.dto'; @@ -124,6 +125,21 @@ export class IdentitiesService { return this.transactionsService.submit(registerIdentity, params, options); } + public async registerDidAsRegistrar(registerDidDto: RegisterDidDto): ServiceReturn { + const { + polymeshService: { polymeshApi }, + } = this; + + const { + options, + args: { targetAccount }, + } = extractTxOptions(registerDidDto); + + const { registerDid } = polymeshApi.identities; + + return this.transactionsService.submit(registerDid, { targetAccount }, options); + } + public async rotatePrimaryKey( rotatePrimaryKeyDto: RotatePrimaryKeyParamsDto ): ServiceReturn { diff --git a/src/main.ts b/src/main.ts index ec241d58..7c15991b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { AppErrorToHttpResponseFilter } from '~/common/filters/app-error-to-http import { LoggingInterceptor } from '~/common/interceptors/logging.interceptor'; import { WebhookResponseCodeInterceptor } from '~/common/interceptors/webhook-response-code.interceptor'; import { swaggerDescription, swaggerTitle } from '~/common/utils'; +import { isDeveloperUtilsEnabled } from '~/common/utils/feature-flags'; import { DeveloperTestingService } from '~/developer-testing/developer-testing.service'; import { CoverageInterceptor } from '~/developer-testing/interceptors/coverage.interceptor'; import { PolymeshLogger } from '~/logger/polymesh-logger.service'; @@ -69,9 +70,9 @@ async function bootstrap(): Promise { new WebhookResponseCodeInterceptor() ); - // If developer service is present use an interceptor to track coverage - const developerService = app.get(DeveloperTestingService); - if (developerService) { + // If developer utils are enabled use an interceptor to track coverage + if (isDeveloperUtilsEnabled()) { + const developerService = app.get(DeveloperTestingService); developerService.loadSwagger(document); app.useGlobalInterceptors(new CoverageInterceptor(developerService)); } diff --git a/src/nfts/dto/issue-nft.dto.ts b/src/nfts/dto/issue-nft.dto.ts index 361729f7..1a991a03 100644 --- a/src/nfts/dto/issue-nft.dto.ts +++ b/src/nfts/dto/issue-nft.dto.ts @@ -1,8 +1,8 @@ /* istanbul ignore file */ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ValidateNested } from 'class-validator'; +import { IsOptional, IsString, ValidateNested } from 'class-validator'; import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; import { MetadataValueDto } from '~/nfts/dto/metadata-value.dto'; @@ -16,4 +16,13 @@ export class IssueNftDto extends TransactionBaseDto { @Type(() => MetadataValueDto) @ValidateNested({ each: true }) readonly metadata: MetadataValueDto[]; + + @ApiPropertyOptional({ + description: + "Issues the NFT to this Account's asset holdings instead of a Portfolio. Must be the signing Account - any other value is rejected. When omitted, the NFT is issued to the signer's default portfolio", + example: '5EjsqfmY4JqMSrt7YQCe3if5DK4FrG98uUwZsaXmNW7aKdNM', + }) + @IsOptional() + @IsString() + readonly account?: string; } diff --git a/src/polymesh/config/polymesh.config.ts b/src/polymesh/config/polymesh.config.ts index 83d35b34..fee98a5c 100644 --- a/src/polymesh/config/polymesh.config.ts +++ b/src/polymesh/config/polymesh.config.ts @@ -4,7 +4,7 @@ import { registerAs } from '@nestjs/config'; interface MiddlewareConfig { link: string; - key: string; + key?: string; } interface Config { @@ -19,6 +19,7 @@ export default registerAs('polymesh', () => { POLYMESH_MIDDLEWARE_URL, POLYMESH_MIDDLEWARE_API_KEY, POLYMESH_MIDDLEWARE_V2_URL, + POLYMESH_MIDDLEWARE_V2_API_KEY, } = process.env; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -29,7 +30,9 @@ export default registerAs('polymesh', () => { } if (POLYMESH_MIDDLEWARE_V2_URL) { - config.middlewareV2 = { link: POLYMESH_MIDDLEWARE_V2_URL, key: '' }; + // an indexer that requires no authentication needs no key - omit it entirely rather than + // sending an empty `x-api-key` header + config.middlewareV2 = { link: POLYMESH_MIDDLEWARE_V2_URL, key: POLYMESH_MIDDLEWARE_V2_API_KEY }; } return config; diff --git a/src/portfolios/decorators/swagger.ts b/src/portfolios/decorators/swagger.ts new file mode 100644 index 00000000..ee2b8012 --- /dev/null +++ b/src/portfolios/decorators/swagger.ts @@ -0,0 +1,46 @@ +/* istanbul ignore file */ + +import { applyDecorators, HttpStatus } from '@nestjs/common'; +import { ApiParam } from '@nestjs/swagger'; + +import { ApiTransactionFailedResponse, ApiTransactionResponse } from '~/common/decorators'; +import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; + +/** + * Shared swagger docs for the Portfolio Asset pre-approval transaction endpoints + * (`preApproveAsset` / `removeAssetPreApproval`), which differ only in their + * `id` param description and unprocessable entity message + */ +export function ApiPortfolioAssetPreApprovalResponses({ + idDescription, + unprocessableEntityMessage, +}: { + idDescription: string; + unprocessableEntityMessage: string; +}): ReturnType { + return applyDecorators( + ApiParam({ + name: 'did', + description: 'The DID of the Portfolio owner', + type: 'string', + example: '0x0600000000000000000000000000000000000000000000000000000000000000', + }), + ApiParam({ + name: 'id', + description: idDescription, + type: 'string', + example: '1', + }), + ApiTransactionResponse({ + description: 'Information about the transaction', + type: TransactionQueueModel, + }), + ApiTransactionFailedResponse({ + [HttpStatus.NOT_FOUND]: [ + 'The Portfolio with provided ID was not found', + 'The Identity with provided DID was not found', + ], + [HttpStatus.UNPROCESSABLE_ENTITY]: [unprocessableEntityMessage], + }) + ); +} diff --git a/src/portfolios/dto/pre-approve-asset.dto.ts b/src/portfolios/dto/pre-approve-asset.dto.ts new file mode 100644 index 00000000..6c3efafa --- /dev/null +++ b/src/portfolios/dto/pre-approve-asset.dto.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; + +import { IsAsset } from '~/common/decorators/validation'; +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; + +export class PreApproveAssetDto extends TransactionBaseDto { + @ApiProperty({ + description: 'The Asset (Ticker/Asset ID) to pre-approve', + example: '3616b82e-8e10-80ae-dc95-2ea28b9db8b3', + }) + @IsAsset() + readonly asset: string; +} diff --git a/src/portfolios/porfolios.controller.spec.ts b/src/portfolios/porfolios.controller.spec.ts index ea73dfed..79481aca 100644 --- a/src/portfolios/porfolios.controller.spec.ts +++ b/src/portfolios/porfolios.controller.spec.ts @@ -13,7 +13,12 @@ import { PortfoliosController } from '~/portfolios/portfolios.controller'; import { PortfoliosService } from '~/portfolios/portfolios.service'; import { createPortfolioIdentifierModel, createPortfolioModel } from '~/portfolios/portfolios.util'; import { processedTxResult, testValues } from '~/test-utils/consts'; -import { createMockResultSet, MockHistoricSettlement, MockPortfolio } from '~/test-utils/mocks'; +import { + createMockResultSet, + MockAsset, + MockHistoricSettlement, + MockPortfolio, +} from '~/test-utils/mocks'; import { MockPortfoliosService } from '~/test-utils/service-mocks'; const { did, signer, txResult, assetId } = testValues; @@ -286,4 +291,88 @@ describe('PortfoliosController', () => { }); }); }); + + describe('preApproveAsset', () => { + it('should return the transaction details', async () => { + mockPortfoliosService.preApproveAsset.mockResolvedValue(txResult); + + const portfolioParams = new PortfolioDto({ id: new BigNumber(1), did }); + const body = { signer, asset: assetId }; + + const result = await controller.preApproveAsset(portfolioParams, body); + + expect(result).toEqual(processedTxResult); + expect(mockPortfoliosService.preApproveAsset).toHaveBeenCalledWith(portfolioParams, body); + }); + }); + + describe('removeAssetPreApproval', () => { + it('should return the transaction details', async () => { + mockPortfoliosService.removeAssetPreApproval.mockResolvedValue(txResult); + + const portfolioParams = new PortfolioDto({ id: new BigNumber(1), did }); + const body = { signer, asset: assetId }; + + const result = await controller.removeAssetPreApproval(portfolioParams, body); + + expect(result).toEqual(processedTxResult); + expect(mockPortfoliosService.removeAssetPreApproval).toHaveBeenCalledWith( + portfolioParams, + body + ); + }); + }); + + describe('getIsAssetPreApproved', () => { + it('should return the asset pre-approval status', async () => { + mockPortfoliosService.isAssetPreApproved.mockResolvedValue(true); + + const portfolioParams = new PortfolioDto({ id: new BigNumber(1), did }); + + const result = await controller.getIsAssetPreApproved(portfolioParams, { asset: assetId }); + + expect(result).toEqual({ asset: assetId, did, isPreApproved: true }); + }); + }); + + describe('getPreApprovedAssets', () => { + const paginatedResult = { + data: [new MockAsset()], + next: null, + count: new BigNumber(1), + }; + + it('should return pre-approved assets without start value', async () => { + mockPortfoliosService.getPreApprovedAssets.mockResolvedValue(paginatedResult); + + const portfolioParams = new PortfolioDto({ id: new BigNumber(1), did }); + + const result = await controller.getPreApprovedAssets(portfolioParams, { + size: new BigNumber(10), + }); + + expect(result).toEqual({ + total: paginatedResult.count, + next: paginatedResult.next, + results: [expect.objectContaining({ asset: assetId, did, isPreApproved: true })], + }); + }); + + it('should give pre-approved assets with start value', async () => { + mockPortfoliosService.getPreApprovedAssets.mockResolvedValue(paginatedResult); + + const portfolioParams = new PortfolioDto({ id: new BigNumber(1), did }); + + const result = await controller.getPreApprovedAssets(portfolioParams, { + size: new BigNumber(10), + start: new BigNumber(1), + }); + + expect(result).toEqual({ + total: paginatedResult.count, + next: paginatedResult.next, + results: [expect.objectContaining({ asset: assetId, did, isPreApproved: true })], + }); + }); + }); }); diff --git a/src/portfolios/portfolios.controller.ts b/src/portfolios/portfolios.controller.ts index c25be688..58e57cd9 100644 --- a/src/portfolios/portfolios.controller.ts +++ b/src/portfolios/portfolios.controller.ts @@ -18,6 +18,7 @@ import { } from '@nestjs/swagger'; import { NumberedPortfolio } from '@polymeshassociation/polymesh-sdk/types'; +import { AssetParamsDto } from '~/assets/dto/asset-params.dto'; import { ApiArrayResponse, ApiTransactionFailedResponse, @@ -31,12 +32,15 @@ import { PaginatedResultsModel } from '~/common/models/paginated-results.model'; import { ResultsModel } from '~/common/models/results.model'; import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; import { handleServiceResult, TransactionResolver, TransactionResponseModel } from '~/common/utils'; +import { PreApprovedModel } from '~/identities/models/pre-approved.model'; import { PolymeshLogger } from '~/logger/polymesh-logger.service'; +import { ApiPortfolioAssetPreApprovalResponses } from '~/portfolios/decorators/swagger'; import { AssetMovementDto } from '~/portfolios/dto/asset-movement.dto'; import { CreatePortfolioDto } from '~/portfolios/dto/create-portfolio.dto'; import { GetTransactionsDto } from '~/portfolios/dto/get-transactions.dto'; import { ModifyPortfolioDto } from '~/portfolios/dto/modify-portfolio.dto'; import { PortfolioDto } from '~/portfolios/dto/portfolio.dto'; +import { PreApproveAssetDto } from '~/portfolios/dto/pre-approve-asset.dto'; import { SetCustodianDto } from '~/portfolios/dto/set-custodian.dto'; import { CreatedPortfolioModel } from '~/portfolios/models/created-portfolio.model'; import { HistoricSettlementModel } from '~/portfolios/models/historic-settlement.model'; @@ -50,7 +54,7 @@ import { createPortfolioIdentifierModel, createPortfolioModel } from '~/portfoli export class PortfoliosController { constructor( private readonly portfoliosService: PortfoliosService, - private logger: PolymeshLogger + private readonly logger: PolymeshLogger ) { logger.setContext(PortfoliosService.name); } @@ -421,4 +425,111 @@ export class PortfoliosController { return new EventIdentifierModel(result); } + + @ApiOperation({ + summary: 'Pre-approve receiving an Asset for a Portfolio', + description: + 'This endpoint pre-approves receiving an Asset for the given Portfolio, so incoming transfers of it auto-affirm without a manual affirm step', + }) + @ApiPortfolioAssetPreApprovalResponses({ + idDescription: 'The ID of the Portfolio for which to pre-approve the Asset', + unprocessableEntityMessage: 'The Portfolio has already pre-approved the Asset', + }) + @Post('/identities/:did/portfolios/:id/pre-approve-asset') + public async preApproveAsset( + @Param() portfolioParams: PortfolioDto, + @Body() params: PreApproveAssetDto + ): Promise { + const result = await this.portfoliosService.preApproveAsset(portfolioParams, params); + + return handleServiceResult(result); + } + + @ApiOperation({ + summary: 'Remove pre-approval for receiving an Asset for a Portfolio', + description: + 'This endpoint disables automatic affirmation when the Portfolio receives the Asset', + }) + @ApiPortfolioAssetPreApprovalResponses({ + idDescription: 'The ID of the Portfolio for which to remove the Asset pre-approval', + unprocessableEntityMessage: 'The Asset is not pre-approved for the Portfolio', + }) + @Post('/identities/:did/portfolios/:id/remove-pre-approval') + public async removeAssetPreApproval( + @Param() portfolioParams: PortfolioDto, + @Body() params: PreApproveAssetDto + ): Promise { + const result = await this.portfoliosService.removeAssetPreApproval(portfolioParams, params); + + return handleServiceResult(result); + } + + @ApiOperation({ + summary: 'Check if an Asset is pre-approved for a Portfolio', + description: 'This endpoint returns whether or not an Asset is pre-approved for a Portfolio', + }) + @ApiParam({ + name: 'did', + description: 'The DID of the Portfolio owner', + type: 'string', + example: '0x0600000000000000000000000000000000000000000000000000000000000000', + }) + @ApiParam({ + name: 'id', + description: 'The ID of the Portfolio for which to check the Asset pre-approval', + type: 'string', + example: '1', + }) + @ApiOkResponse({ + description: 'Returns pre-approval status for the Asset', + type: PreApprovedModel, + }) + @Get('/identities/:did/portfolios/:id/is-pre-approved') + public async getIsAssetPreApproved( + @Param() portfolioParams: PortfolioDto, + @Query() { asset }: AssetParamsDto + ): Promise { + const { did } = portfolioParams; + const isPreApproved = await this.portfoliosService.isAssetPreApproved(portfolioParams, asset); + + return new PreApprovedModel({ asset, did, isPreApproved }); + } + + @ApiOperation({ + summary: 'Get Assets pre-approved by a Portfolio', + description: 'This endpoint returns the Assets the given Portfolio has pre-approved', + }) + @ApiParam({ + name: 'did', + description: 'The DID of the Portfolio owner', + type: 'string', + example: '0x0600000000000000000000000000000000000000000000000000000000000000', + }) + @ApiParam({ + name: 'id', + description: 'The ID of the Portfolio for which to fetch pre-approved Assets', + type: 'string', + example: '1', + }) + @ApiOkResponse({ + description: 'Returns pre-approved Assets for the Portfolio', + type: PaginatedResultsModel, + }) + @Get('/identities/:did/portfolios/:id/pre-approved-assets') + public async getPreApprovedAssets( + @Param() portfolioParams: PortfolioDto, + @Query() { size, start }: PaginatedParamsDto + ): Promise> { + const { did } = portfolioParams; + const { data, count, next } = await this.portfoliosService.getPreApprovedAssets( + portfolioParams, + { size, start: start?.toString() } + ); + + return new PaginatedResultsModel({ + results: data.map(({ id }) => new PreApprovedModel({ asset: id, did, isPreApproved: true })), + total: count, + next, + }); + } } diff --git a/src/portfolios/portfolios.service.spec.ts b/src/portfolios/portfolios.service.spec.ts index 385b9c67..9704f804 100644 --- a/src/portfolios/portfolios.service.spec.ts +++ b/src/portfolios/portfolios.service.spec.ts @@ -528,4 +528,118 @@ describe('PortfoliosService', () => { }); }); }); + + describe('preApproveAsset', () => { + it('should return the transaction details', async () => { + const transaction = { + blockHash: '0x1', + txHash: '0x2', + blockNumber: new BigNumber(1), + tag: TxTags.asset.PreApproveAsset, + }; + const mockTransaction = new MockTransaction(transaction); + const mockPortfolio = new MockPortfolio(); + const mockIdentity = new MockIdentity(); + + mockIdentitiesService.findOne.mockResolvedValue(mockIdentity); + mockIdentity.portfolios.getPortfolio.mockResolvedValue(mockPortfolio); + mockPortfolio.preApproveAsset.mockResolvedValue(mockTransaction); + + mockTransactionsService.submit.mockResolvedValue({ + result: undefined, + transactions: [mockTransaction], + }); + + const result = await service.preApproveAsset( + new PortfolioDto({ did, id: mockPortfolio.id }), + { signer, asset: assetId } + ); + + expect(result).toEqual({ + result: undefined, + transactions: [mockTransaction], + }); + expect(mockTransactionsService.submit).toHaveBeenCalledWith( + mockPortfolio.preApproveAsset, + { asset: assetId }, + expect.objectContaining({ signer }) + ); + }); + }); + + describe('removeAssetPreApproval', () => { + it('should return the transaction details', async () => { + const transaction = { + blockHash: '0x1', + txHash: '0x2', + blockNumber: new BigNumber(1), + tag: TxTags.asset.RemoveAssetPreApproval, + }; + const mockTransaction = new MockTransaction(transaction); + const mockPortfolio = new MockPortfolio(); + const mockIdentity = new MockIdentity(); + + mockIdentitiesService.findOne.mockResolvedValue(mockIdentity); + mockIdentity.portfolios.getPortfolio.mockResolvedValue(mockPortfolio); + mockPortfolio.removeAssetPreApproval.mockResolvedValue(mockTransaction); + + mockTransactionsService.submit.mockResolvedValue({ + result: undefined, + transactions: [mockTransaction], + }); + + const result = await service.removeAssetPreApproval( + new PortfolioDto({ did, id: mockPortfolio.id }), + { signer, asset: assetId } + ); + + expect(result).toEqual({ + result: undefined, + transactions: [mockTransaction], + }); + expect(mockTransactionsService.submit).toHaveBeenCalledWith( + mockPortfolio.removeAssetPreApproval, + { asset: assetId }, + expect.objectContaining({ signer }) + ); + }); + }); + + describe('isAssetPreApproved', () => { + it('should return whether the Asset is pre-approved for the Portfolio', async () => { + const mockPortfolio = new MockPortfolio(); + const mockIdentity = new MockIdentity(); + + mockIdentitiesService.findOne.mockResolvedValue(mockIdentity); + mockIdentity.portfolios.getPortfolio.mockResolvedValue(mockPortfolio); + mockPortfolio.isAssetPreApproved.mockResolvedValue(true); + + const result = await service.isAssetPreApproved( + new PortfolioDto({ did, id: mockPortfolio.id }), + assetId + ); + + expect(result).toBe(true); + expect(mockPortfolio.isAssetPreApproved).toHaveBeenCalledWith(assetId); + }); + }); + + describe('getPreApprovedAssets', () => { + it('should return the pre-approved Assets for the Portfolio', async () => { + const mockPortfolio = new MockPortfolio(); + const mockIdentity = new MockIdentity(); + const mockResultSet = createMockResultSet([{ id: assetId }]); + + mockIdentitiesService.findOne.mockResolvedValue(mockIdentity); + mockIdentity.portfolios.getPortfolio.mockResolvedValue(mockPortfolio); + mockPortfolio.preApprovedAssets.mockResolvedValue(mockResultSet); + + const result = await service.getPreApprovedAssets( + new PortfolioDto({ did, id: mockPortfolio.id }), + { size: new BigNumber(10) } + ); + + expect(result).toEqual(mockResultSet); + }); + }); }); diff --git a/src/portfolios/portfolios.service.ts b/src/portfolios/portfolios.service.ts index 28c2d6ca..a72187f6 100644 --- a/src/portfolios/portfolios.service.ts +++ b/src/portfolios/portfolios.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { + Asset, AuthorizationRequest, DefaultPortfolio, EventIdentifier, @@ -20,6 +21,7 @@ import { AssetMovementDto } from '~/portfolios/dto/asset-movement.dto'; import { CreatePortfolioDto } from '~/portfolios/dto/create-portfolio.dto'; import { ModifyPortfolioDto } from '~/portfolios/dto/modify-portfolio.dto'; import { PortfolioDto } from '~/portfolios/dto/portfolio.dto'; +import { PreApproveAssetDto } from '~/portfolios/dto/pre-approve-asset.dto'; import { SetCustodianDto } from '~/portfolios/dto/set-custodian.dto'; import { toPortfolioId } from '~/portfolios/portfolios.util'; import { TransactionsService } from '~/transactions/transactions.service'; @@ -173,4 +175,49 @@ export class PortfoliosService { const portfolio = await this.findOne(did, portfolioId); return portfolio.createdAt(); } + + public async preApproveAsset( + portfolioParams: PortfolioDto, + params: PreApproveAssetDto + ): ServiceReturn { + const { did, id } = portfolioParams; + const { + options, + args: { asset }, + } = extractTxOptions(params); + const portfolio = await this.findOne(did, id); + + return this.transactionsService.submit(portfolio.preApproveAsset, { asset }, options); + } + + public async removeAssetPreApproval( + portfolioParams: PortfolioDto, + params: PreApproveAssetDto + ): ServiceReturn { + const { did, id } = portfolioParams; + const { + options, + args: { asset }, + } = extractTxOptions(params); + const portfolio = await this.findOne(did, id); + + return this.transactionsService.submit(portfolio.removeAssetPreApproval, { asset }, options); + } + + public async isAssetPreApproved(portfolioParams: PortfolioDto, asset: string): Promise { + const { did, id } = portfolioParams; + const portfolio = await this.findOne(did, id); + + return portfolio.isAssetPreApproved(asset); + } + + public async getPreApprovedAssets( + portfolioParams: PortfolioDto, + paginationOptions: PaginationOptions + ): Promise> { + const { did, id } = portfolioParams; + const portfolio = await this.findOne(did, id); + + return portfolio.preApprovedAssets(paginationOptions); + } } diff --git a/src/settlements/dto/create-instruction.dto.ts b/src/settlements/dto/create-instruction.dto.ts index c768ec88..cab1e0b0 100644 --- a/src/settlements/dto/create-instruction.dto.ts +++ b/src/settlements/dto/create-instruction.dto.ts @@ -3,7 +3,14 @@ import { ApiExtraModels, ApiPropertyOptional } from '@nestjs/swagger'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { Type } from 'class-transformer'; -import { IsByteLength, IsDate, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsBoolean, + IsByteLength, + IsDate, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; import { ApiPropertyOneOf } from '~/common/decorators/swagger'; import { ToBigNumber } from '~/common/decorators/transformation'; @@ -78,6 +85,16 @@ export class CreateInstructionDto extends TransactionBaseDto { @ToBigNumber() readonly endAfterBlock?: BigNumber; + @ApiPropertyOptional({ + type: 'boolean', + description: + 'If true, the Instruction will only execute once all parties (including mediators) have affirmed and a mediator has locked it for execution via lockForExecution. Ignored if endBlock or endAfterBlock is also provided. Requires at least one mediator', + example: true, + }) + @IsOptional() + @IsBoolean() + readonly endAfterLock?: boolean; + @ApiPropertyOptional({ description: 'Identifier string to help differentiate instructions. Maximum 32 bytes', example: 'Transfer of GROWTH Asset', diff --git a/src/settlements/dto/offchain-affirmation-receipt.dto.ts b/src/settlements/dto/offchain-affirmation-receipt.dto.ts index d572e437..6dcc7c44 100644 --- a/src/settlements/dto/offchain-affirmation-receipt.dto.ts +++ b/src/settlements/dto/offchain-affirmation-receipt.dto.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { Type } from 'class-transformer'; import { IsDate, IsOptional, IsString, ValidateNested } from 'class-validator'; @@ -54,15 +54,13 @@ export class OffChainAffirmationReceiptDto { @IsString() readonly metadata?: string; - @ApiPropertyOptional({ - description: - 'Expiration date for the off-chain affirmation receipt. Required on chain v8 when generating receipts', + @ApiProperty({ + description: 'Expiration date for the off-chain affirmation receipt', type: 'string', example: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), }) - @IsOptional() @IsDate() - readonly expiresAt?: Date; + readonly expiresAt: Date; constructor(dto: OffChainAffirmationReceiptDto) { Object.assign(this, dto); diff --git a/src/settlements/models/leg-status.model.ts b/src/settlements/models/leg-status.model.ts new file mode 100644 index 00000000..48c95249 --- /dev/null +++ b/src/settlements/models/leg-status.model.ts @@ -0,0 +1,38 @@ +/* istanbul ignore file */ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { LegStatusType } from '@polymeshassociation/polymesh-sdk/types'; + +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class LegStatusModel { + @ApiProperty({ + description: 'The status of the Instruction leg', + type: 'string', + enum: LegStatusType, + example: LegStatusType.ExecutionPending, + }) + readonly type: LegStatusType; + + @ApiPropertyOptional({ + description: + 'The Account that skipped executing this leg. Present when type is ExecutionToBeSkipped', + type: 'string', + example: '5GwwYnwCYcJ1Rkop35y7SDHAzbxrCkNUDD4YuCUJRPPXbvyV', + }) + readonly signer?: string; + + @ApiPropertyOptional({ + description: + 'Unique ID of the off-chain receipt that caused this leg to be skipped. Present when type is ExecutionToBeSkipped', + type: 'string', + example: '1', + }) + @FromBigNumber() + readonly uid?: BigNumber; + + constructor(model: LegStatusModel) { + Object.assign(this, model); + } +} diff --git a/src/settlements/models/relock-status.model.ts b/src/settlements/models/relock-status.model.ts new file mode 100644 index 00000000..1b3a6848 --- /dev/null +++ b/src/settlements/models/relock-status.model.ts @@ -0,0 +1,46 @@ +/* istanbul ignore file */ + +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; + +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class RelockStatusModel { + @ApiPropertyOptional({ + description: + 'Date and time when the Instruction was last unlocked by a mediator, null if it has never been unlocked', + type: 'string', + example: new Date('10/14/1987').toISOString(), + nullable: true, + }) + readonly unlockedAt: Date | null; + + @ApiPropertyOptional({ + description: 'The number of times the Instruction has been relocked', + type: 'string', + example: '0', + }) + @FromBigNumber() + readonly relockCount: BigNumber; + + @ApiPropertyOptional({ + description: 'The maximum number of times the Instruction can be relocked', + type: 'string', + example: '1', + }) + @FromBigNumber() + readonly maxRelockCount: BigNumber; + + @ApiPropertyOptional({ + description: + 'Date and time after which the Instruction can be locked again, null if it has never been unlocked', + type: 'string', + example: new Date('10/14/1987').toISOString(), + nullable: true, + }) + readonly cooldownEndsAt: Date | null; + + constructor(model: RelockStatusModel) { + Object.assign(this, model); + } +} diff --git a/src/settlements/models/signer-count.model.ts b/src/settlements/models/signer-count.model.ts new file mode 100644 index 00000000..fb2ac8d8 --- /dev/null +++ b/src/settlements/models/signer-count.model.ts @@ -0,0 +1,20 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; + +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class SignerCountModel { + @ApiProperty({ + description: 'The number of signers allowed by the Venue', + type: 'string', + example: '1', + }) + @FromBigNumber() + readonly count: BigNumber; + + constructor(model: SignerCountModel) { + Object.assign(this, model); + } +} diff --git a/src/settlements/models/transfer-breakdown.model.ts b/src/settlements/models/transfer-breakdown.model.ts index dae3e6ec..123902ae 100644 --- a/src/settlements/models/transfer-breakdown.model.ts +++ b/src/settlements/models/transfer-breakdown.model.ts @@ -14,7 +14,7 @@ export class TransferBreakdownModel { items: { oneOf: [{ type: 'string', enum: Object.values(TransferError) }, { type: 'string' }], }, - example: [TransferError.InvalidSenderPortfolio, TransferError.InvalidSenderCdd], + example: [TransferError.InvalidSenderPortfolio, TransferError.InvalidReceiverIdentity], }) readonly general: (TransferError | string)[]; diff --git a/src/settlements/settlements.controller.spec.ts b/src/settlements/settlements.controller.spec.ts index db4d6c45..6a7c3f9d 100644 --- a/src/settlements/settlements.controller.spec.ts +++ b/src/settlements/settlements.controller.spec.ts @@ -7,6 +7,7 @@ import { Identity, InstructionStatus, InstructionType, + LegStatusType, Nft, TransferError, } from '@polymeshassociation/polymesh-sdk/types'; @@ -23,6 +24,8 @@ import { InstructionAffirmationPartyType, } from '~/settlements/models/instruction-affirmation.model'; import { LegModel } from '~/settlements/models/leg.model'; +import { LegStatusModel } from '~/settlements/models/leg-status.model'; +import { RelockStatusModel } from '~/settlements/models/relock-status.model'; import { SettlementsController } from '~/settlements/settlements.controller'; import { SettlementsService } from '~/settlements/settlements.service'; import * as settlementsUtil from '~/settlements/settlements.util'; @@ -275,17 +278,6 @@ describe('SettlementsController', () => { }); }); - describe('withdrawAffirmation', () => { - it('should withdraw affirmation from an instruction and return the data returned by the service', async () => { - await testControllerTxResult( - controller.withdrawAffirmation.bind(controller), - mockSettlementsService.withdrawAffirmation, - { id: new BigNumber(3) }, - { signer: 'signer' } - ); - }); - }); - describe('affirmInstructionAsMediator', () => { it('should affirm an instruction and return the data returned by the service', async () => { await testControllerTxResult( @@ -309,17 +301,81 @@ describe('SettlementsController', () => { }); }); - describe('withdrawAffirmationAsMediator', () => { - it('should withdraw affirmation from an instruction and return the data returned by the service', async () => { + describe('lockInstructionForExecution', () => { + it('should lock an instruction and return the data returned by the service', async () => { + await testControllerTxResult( + controller.lockInstructionForExecution.bind(controller), + mockSettlementsService.lockInstructionForExecution, + { id: new BigNumber(3) }, + { signer: 'signer' } + ); + }); + }); + + describe('unlockInstructionForExecution', () => { + it('should unlock an instruction and return the data returned by the service', async () => { await testControllerTxResult( - controller.withdrawAffirmationAsMediator.bind(controller), - mockSettlementsService.withdrawAffirmationAsMediator, + controller.unlockInstructionForExecution.bind(controller), + mockSettlementsService.unlockInstructionForExecution, { id: new BigNumber(3) }, { signer: 'signer' } ); }); }); + describe('getRelockStatus', () => { + it('should return the relock status of the Instruction', async () => { + const mockRelockStatus = { + unlockedAt: null, + relockCount: new BigNumber(0), + maxRelockCount: new BigNumber(1), + cooldownEndsAt: null, + }; + mockSettlementsService.getRelockStatus.mockResolvedValue(mockRelockStatus); + + const result = await controller.getRelockStatus({ id: new BigNumber(3) }); + + expect(result).toEqual(new RelockStatusModel(mockRelockStatus)); + }); + }); + + describe('getLegStatus', () => { + it('should return the status of a Leg with ExecutionPending', async () => { + mockSettlementsService.getLegStatus.mockResolvedValue({ + type: LegStatusType.ExecutionPending, + }); + + const result = await controller.getLegStatus({ + id: new BigNumber(3), + legId: new BigNumber(0), + }); + + expect(result).toEqual(new LegStatusModel({ type: LegStatusType.ExecutionPending })); + }); + + it('should return the status of a Leg with ExecutionToBeSkipped, mapping the signer to its address', async () => { + const address = '5GwwYnwCYcJ1Rkop35y7SDHAzbxrCkNUDD4YuCUJRPPXbvyV'; + mockSettlementsService.getLegStatus.mockResolvedValue({ + type: LegStatusType.ExecutionToBeSkipped, + signer: { address }, + uid: new BigNumber(1), + }); + + const result = await controller.getLegStatus({ + id: new BigNumber(3), + legId: new BigNumber(0), + }); + + expect(result).toEqual( + new LegStatusModel({ + type: LegStatusType.ExecutionToBeSkipped, + signer: address, + uid: new BigNumber(1), + }) + ); + }); + }); + describe('getAffirmations', () => { it('should return the list of affirmations generated for a Instruction', async () => { const mockAffirmations = { @@ -453,7 +509,7 @@ describe('SettlementsController', () => { describe('validateLeg', () => { it('should call the service and return the Leg validations', async () => { const mockTransferBreakdown = { - general: [TransferError.SelfTransfer, TransferError.ScopeClaimMissing], + general: [TransferError.SelfTransfer, TransferError.InvalidReceiverIdentity], compliance: { requirements: [], complies: false, diff --git a/src/settlements/settlements.controller.ts b/src/settlements/settlements.controller.ts index 5ca73cdc..03678184 100644 --- a/src/settlements/settlements.controller.ts +++ b/src/settlements/settlements.controller.ts @@ -7,7 +7,7 @@ import { ApiQuery, ApiTags, } from '@nestjs/swagger'; -import { Instruction } from '@polymeshassociation/polymesh-sdk/types'; +import { Instruction, LegStatusType } from '@polymeshassociation/polymesh-sdk/types'; import { ApiArrayResponse } from '~/common/decorators/swagger'; import { AssetHolderDto } from '~/common/dto/asset-holder.dto'; @@ -31,7 +31,9 @@ import { InstructionAffirmationModel, InstructionAffirmationPartyType, } from '~/settlements/models/instruction-affirmation.model'; +import { LegStatusModel } from '~/settlements/models/leg-status.model'; import { OffChainAffirmationModel } from '~/settlements/models/off-chain-affirmation.model'; +import { RelockStatusModel } from '~/settlements/models/relock-status.model'; import { TransferBreakdownModel } from '~/settlements/models/transfer-breakdown.model'; import { SettlementsService } from '~/settlements/settlements.service'; import { createInstructionModel, legsToLegModel } from '~/settlements/settlements.util'; @@ -116,14 +118,12 @@ export class SettlementsController { @ApiTags('instructions') @ApiOperation({ - summary: 'Withdraw affirmation from an existing Instruction', - description: - 'This endpoint will withdraw an affirmation from an Instruction. Deprecated on chain v8 where affirmation withdraw is no longer supported', - deprecated: true, + summary: 'Affirm an existing Instruction as a mediator', + description: 'This endpoint will affirm a pending Instruction as a mediator', }) @ApiParam({ name: 'id', - description: 'The ID of the Instruction from which to withdraw the affirmation', + description: 'The ID of the Instruction to be affirmed', type: 'string', example: '123', }) @@ -131,27 +131,23 @@ export class SettlementsController { description: 'Details of the transaction', type: TransactionQueueModel, }) - @ApiNotFoundResponse({ - description: 'The requested Instruction was not found', - }) - @Post('instructions/:id/withdraw') - public async withdrawAffirmation( + @Post('instructions/:id/affirm-as-mediator') + public async affirmInstructionAsMediator( @Param() { id }: IdParamsDto, - @Body() signerDto: TransactionBaseDto + @Body() signerDto: AffirmAsMediatorDto ): Promise { - const result = await this.settlementsService.withdrawAffirmation(id, signerDto); - + const result = await this.settlementsService.affirmInstructionAsMediator(id, signerDto); return handleServiceResult(result); } @ApiTags('instructions') @ApiOperation({ - summary: 'Affirm an existing Instruction as a mediator', - description: 'This endpoint will affirm a pending Instruction as a mediator', + summary: 'Reject an existing Instruction as a mediator', + description: 'This endpoint will reject a pending Instruction', }) @ApiParam({ name: 'id', - description: 'The ID of the Instruction to be affirmed', + description: 'The ID of the Instruction to be rejected', type: 'string', example: '123', }) @@ -159,23 +155,24 @@ export class SettlementsController { description: 'Details of the transaction', type: TransactionQueueModel, }) - @Post('instructions/:id/affirm-as-mediator') - public async affirmInstructionAsMediator( + @Post('instructions/:id/reject-as-mediator') + public async rejectInstructionAsMediator( @Param() { id }: IdParamsDto, - @Body() signerDto: AffirmAsMediatorDto + @Body() signerDto: TransactionBaseDto ): Promise { - const result = await this.settlementsService.affirmInstructionAsMediator(id, signerDto); + const result = await this.settlementsService.rejectInstructionAsMediator(id, signerDto); return handleServiceResult(result); } @ApiTags('instructions') @ApiOperation({ - summary: 'Reject an existing Instruction as a mediator', - description: 'This endpoint will reject a pending Instruction', + summary: 'Lock an Instruction for execution', + description: + 'This endpoint locks a pending Instruction, moving it into the LockedForExecution status', }) @ApiParam({ name: 'id', - description: 'The ID of the Instruction to be rejected', + description: 'The ID of the Instruction to be locked', type: 'string', example: '123', }) @@ -183,25 +180,24 @@ export class SettlementsController { description: 'Details of the transaction', type: TransactionQueueModel, }) - @Post('instructions/:id/reject-as-mediator') - public async rejectInstructionAsMediator( + @Post('instructions/:id/lock') + public async lockInstructionForExecution( @Param() { id }: IdParamsDto, @Body() signerDto: TransactionBaseDto ): Promise { - const result = await this.settlementsService.rejectInstructionAsMediator(id, signerDto); + const result = await this.settlementsService.lockInstructionForExecution(id, signerDto); return handleServiceResult(result); } @ApiTags('instructions') @ApiOperation({ - summary: 'Withdraw affirmation from an existing Instruction as a mediator', + summary: 'Unlock an Instruction from execution', description: - 'This endpoint will withdraw a mediator affirmation from an Instruction. Deprecated on chain v8 where affirmation withdraw is no longer supported', - deprecated: true, + 'This endpoint moves a LockedForExecution Instruction back to Pending. Can only be called by a mediator on the Instruction, and is subject to a relock cooldown period', }) @ApiParam({ name: 'id', - description: 'The ID of the Instruction from which to withdraw the affirmation', + description: 'The ID of the Instruction to be unlocked', type: 'string', example: '123', }) @@ -209,19 +205,37 @@ export class SettlementsController { description: 'Details of the transaction', type: TransactionQueueModel, }) - @ApiNotFoundResponse({ - description: 'The requested Instruction was not found', - }) - @Post('instructions/:id/withdraw-as-mediator') - public async withdrawAffirmationAsMediator( + @Post('instructions/:id/unlock') + public async unlockInstructionForExecution( @Param() { id }: IdParamsDto, @Body() signerDto: TransactionBaseDto ): Promise { - const result = await this.settlementsService.withdrawAffirmationAsMediator(id, signerDto); - + const result = await this.settlementsService.unlockInstructionForExecution(id, signerDto); return handleServiceResult(result); } + @ApiTags('instructions') + @ApiOperation({ + summary: 'Get the relock status of an Instruction', + description: + "This endpoint returns the mediator's last unlock timestamp, relock count, max relock count and the relock cooldown window for an Instruction", + }) + @ApiParam({ + name: 'id', + description: 'The ID of the Instruction whose relock status is to be fetched', + type: 'string', + example: '123', + }) + @ApiOkResponse({ + description: 'The relock status of the Instruction', + type: RelockStatusModel, + }) + @Get('instructions/:id/relock-status') + public async getRelockStatus(@Param() { id }: IdParamsDto): Promise { + const result = await this.settlementsService.getRelockStatus(id); + return new RelockStatusModel(result); + } + @ApiTags('instructions') @ApiOperation({ summary: 'List of affirmations', @@ -344,6 +358,39 @@ export class SettlementsController { }); } + @ApiTags('instructions') + @ApiOperation({ + summary: 'Get the status of a Leg in an Instruction', + description: 'This endpoint returns the execution status of a specific Leg in an Instruction', + }) + @ApiParam({ + name: 'id', + description: 'The ID of the Instruction the Leg belongs to', + type: 'string', + example: '123', + }) + @ApiParam({ + name: 'legId', + description: 'The index of the Leg whose status is to be fetched', + type: 'string', + example: '0', + }) + @ApiOkResponse({ + description: 'The status of the Leg', + type: LegStatusModel, + }) + @Get('instructions/:id/legs/:legId/status') + public async getLegStatus(@Param() { id, legId }: LegIdParamsDto): Promise { + const result = await this.settlementsService.getLegStatus(id, legId); + + if (result.type === LegStatusType.ExecutionToBeSkipped) { + const { type, signer, uid } = result; + return new LegStatusModel({ type, signer: signer.address, uid }); + } + + return new LegStatusModel(result); + } + @ApiTags('assets') @ApiOperation({ summary: 'Check if a Leg meets the transfer requirements', diff --git a/src/settlements/settlements.service.spec.ts b/src/settlements/settlements.service.spec.ts index 13e670db..62a3aaaa 100644 --- a/src/settlements/settlements.service.spec.ts +++ b/src/settlements/settlements.service.spec.ts @@ -253,6 +253,58 @@ describe('SettlementsService', () => { expect.objectContaining({ signer }) ); }); + + it('should pass through endAfterLock and mediators', async () => { + const mockVenue = new MockVenue(); + const venueId = new BigNumber(123); + const transaction = { + blockHash: '0x1', + txHash: '0x2', + blockNumber: new BigNumber(1), + tag: TxTags.settlement.AddAndAffirmWithMediators, + }; + const mockTransaction = new MockTransaction(transaction); + const mockInstruction = 'instruction'; + mockTransactionsService.submit.mockResolvedValue({ + result: mockInstruction, + transactions: [mockTransaction], + }); + + const findVenueSpy = jest.spyOn(service, 'findVenue'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + findVenueSpy.mockResolvedValue(mockVenue as any); + + const onChainLeg = { + type: LegType.onChain, + from: new PortfolioDto({ did: 'fromDid', id: new BigNumber(0) }), + to: new PortfolioDto({ did: 'toDid', id: new BigNumber(1) }), + amount: new BigNumber(100), + asset: 'FAKE_TICKER', + }; + + const body = { + signer, + legs: [plainToInstance(LegDto, onChainLeg)], + endAfterLock: true, + mediators: ['mediatorDid'], + }; + + const result = await service.createInstruction(venueId, body as CreateInstructionDto); + + expect(result).toEqual({ + result: mockInstruction, + transactions: [mockTransaction], + }); + expect(mockTransactionsService.submit).toHaveBeenCalledWith( + mockPolymeshApi.settlements.addInstruction, + expect.objectContaining({ + endAfterLock: true, + mediators: ['mediatorDid'], + venueId, + }), + expect.objectContaining({ signer }) + ); + }); }); describe('createVenue', () => { @@ -458,6 +510,7 @@ describe('SettlementsService', () => { type: SignerKeyRingType.Sr25519, value: '0xsomesignature', }, + expiresAt: new Date('2026-01-01T00:00:00.000Z'), }); const receipt2 = new OffChainAffirmationReceiptDto({ @@ -469,6 +522,7 @@ describe('SettlementsService', () => { value: '0xsomesignature2', }, metadata: 'random metadata', + expiresAt: new Date('2026-01-01T00:00:00.000Z'), }); mockInstruction.generateOffChainAffirmationReceipt.mockResolvedValue(receipt2); @@ -592,6 +646,21 @@ describe('SettlementsService', () => { }); }); + describe('fetchSignerCount', () => { + it('should return the number of signers allowed by a Venue', async () => { + const mockVenue = new MockVenue(); + mockVenue.getSignerCount.mockResolvedValue(new BigNumber(2)); + + const findVenueSpy = jest.spyOn(service, 'findVenue'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + findVenueSpy.mockResolvedValue(mockVenue as any); + + const result = await service.fetchSignerCount(new BigNumber(12)); + + expect(result).toEqual(new BigNumber(2)); + }); + }); + describe('findAffirmations', () => { it('should return a list of affirmations for an Instruction', async () => { const mockAffirmations = { @@ -663,7 +732,7 @@ describe('SettlementsService', () => { describe('canTransfer', () => { const mockTransferBreakdown = { - general: [TransferError.SelfTransfer, TransferError.ScopeClaimMissing], + general: [TransferError.SelfTransfer, TransferError.InvalidReceiverIdentity], compliance: { requirements: [], complies: false, @@ -739,14 +808,15 @@ describe('SettlementsService', () => { }); }); - describe('withdrawAffirmation', () => { - it('should run a withdraw affirmation procedure and return the queue data', async () => { + describe('affirmInstructionAsMediator', () => { + it('should run an affirm procedure and return the queue data', async () => { + const expiry = new Date(); const mockInstruction = new MockInstruction(); const transaction = { blockHash: '0x1', txHash: '0x2', blockNumber: new BigNumber(1), - tag: TxTags.settlement.WithdrawAffirmation, + tag: TxTags.settlement.AffirmInstructionAsMediator, }; const mockTransaction = new MockTransaction(transaction); mockTransactionsService.submit.mockResolvedValue({ transactions: [mockTransaction] }); @@ -755,31 +825,33 @@ describe('SettlementsService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any findInstructionSpy.mockResolvedValue(mockInstruction as any); - const result = await service.withdrawAffirmation(new BigNumber(123), { + const body = { signer, - }); + expiry, + }; + + const result = await service.affirmInstructionAsMediator(new BigNumber(123), body); expect(result).toEqual({ result: undefined, transactions: [mockTransaction], }); expect(mockTransactionsService.submit).toHaveBeenCalledWith( - mockInstruction.withdraw, - {}, + mockInstruction.affirmAsMediator, + { expiry }, expect.objectContaining({ signer }) ); }); }); - describe('affirmInstructionAsMediator', () => { - it('should run an affirm procedure and return the queue data', async () => { - const expiry = new Date(); + describe('rejectInstructionAsMediator', () => { + it('should run a reject procedure and return the queue data', async () => { const mockInstruction = new MockInstruction(); const transaction = { blockHash: '0x1', txHash: '0x2', blockNumber: new BigNumber(1), - tag: TxTags.settlement.AffirmInstructionAsMediator, + tag: TxTags.settlement.RejectInstructionAsMediator, }; const mockTransaction = new MockTransaction(transaction); mockTransactionsService.submit.mockResolvedValue({ transactions: [mockTransaction] }); @@ -788,33 +860,30 @@ describe('SettlementsService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any findInstructionSpy.mockResolvedValue(mockInstruction as any); - const body = { + const result = await service.rejectInstructionAsMediator(new BigNumber(123), { signer, - expiry, - }; - - const result = await service.affirmInstructionAsMediator(new BigNumber(123), body); + }); expect(result).toEqual({ result: undefined, transactions: [mockTransaction], }); expect(mockTransactionsService.submit).toHaveBeenCalledWith( - mockInstruction.affirmAsMediator, - { expiry }, + mockInstruction.rejectAsMediator, + {}, expect.objectContaining({ signer }) ); }); }); - describe('rejectInstructionAsMediator', () => { - it('should run a reject procedure and return the queue data', async () => { + describe('lockInstructionForExecution', () => { + it('should run a lock procedure and return the queue data', async () => { const mockInstruction = new MockInstruction(); const transaction = { blockHash: '0x1', txHash: '0x2', blockNumber: new BigNumber(1), - tag: TxTags.settlement.RejectInstructionAsMediator, + tag: TxTags.settlement.LockInstruction, }; const mockTransaction = new MockTransaction(transaction); mockTransactionsService.submit.mockResolvedValue({ transactions: [mockTransaction] }); @@ -823,7 +892,7 @@ describe('SettlementsService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any findInstructionSpy.mockResolvedValue(mockInstruction as any); - const result = await service.rejectInstructionAsMediator(new BigNumber(123), { + const result = await service.lockInstructionForExecution(new BigNumber(123), { signer, }); @@ -832,21 +901,21 @@ describe('SettlementsService', () => { transactions: [mockTransaction], }); expect(mockTransactionsService.submit).toHaveBeenCalledWith( - mockInstruction.rejectAsMediator, + mockInstruction.lockForExecution, {}, expect.objectContaining({ signer }) ); }); }); - describe('withdrawAffirmationAsMediator', () => { - it('should run a withdraw affirmation procedure and return the queue data', async () => { + describe('unlockInstructionForExecution', () => { + it('should run an unlock procedure and return the queue data', async () => { const mockInstruction = new MockInstruction(); const transaction = { blockHash: '0x1', txHash: '0x2', blockNumber: new BigNumber(1), - tag: TxTags.settlement.WithdrawAffirmationAsMediator, + tag: TxTags.settlement.UnlockInstruction, }; const mockTransaction = new MockTransaction(transaction); mockTransactionsService.submit.mockResolvedValue({ transactions: [mockTransaction] }); @@ -855,7 +924,7 @@ describe('SettlementsService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any findInstructionSpy.mockResolvedValue(mockInstruction as any); - const result = await service.withdrawAffirmationAsMediator(new BigNumber(123), { + const result = await service.unlockInstructionForExecution(new BigNumber(123), { signer, }); @@ -864,10 +933,48 @@ describe('SettlementsService', () => { transactions: [mockTransaction], }); expect(mockTransactionsService.submit).toHaveBeenCalledWith( - mockInstruction.withdrawAsMediator, + mockInstruction.unlockForExecution, {}, expect.objectContaining({ signer }) ); }); }); + + describe('getRelockStatus', () => { + it('should return the relock status of the Instruction', async () => { + const mockInstruction = new MockInstruction(); + const mockRelockStatus = { + unlockedAt: null, + relockCount: new BigNumber(0), + maxRelockCount: new BigNumber(1), + cooldownEndsAt: null, + }; + mockInstruction.getRelockStatus.mockResolvedValue(mockRelockStatus); + + const findInstructionSpy = jest.spyOn(service, 'findInstruction'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + findInstructionSpy.mockResolvedValue(mockInstruction as any); + + const result = await service.getRelockStatus(new BigNumber(123)); + + expect(result).toEqual(mockRelockStatus); + }); + }); + + describe('getLegStatus', () => { + it('should return the status of the Leg', async () => { + const mockInstruction = new MockInstruction(); + const mockLegStatus = { type: 'ExecutionPending' }; + mockInstruction.getLegStatus.mockResolvedValue(mockLegStatus); + + const findInstructionSpy = jest.spyOn(service, 'findInstruction'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + findInstructionSpy.mockResolvedValue(mockInstruction as any); + + const result = await service.getLegStatus(new BigNumber(123), new BigNumber(0)); + + expect(result).toEqual(mockLegStatus); + expect(mockInstruction.getLegStatus).toHaveBeenCalledWith({ legId: new BigNumber(0) }); + }); + }); }); diff --git a/src/settlements/settlements.service.ts b/src/settlements/settlements.service.ts index 0fa574b6..d4049a66 100644 --- a/src/settlements/settlements.service.ts +++ b/src/settlements/settlements.service.ts @@ -8,6 +8,8 @@ import { GroupedInstructions, Instruction, InstructionAffirmation, + InstructionRelockStatus, + LegStatus, OffChainAffirmation, OffChainAffirmationReceipt, ResultSet, @@ -108,6 +110,12 @@ export class SettlementsService { return venue.getAllowedSigners(); } + public async fetchSignerCount(id: BigNumber): Promise { + const venue = await this.findVenue(id); + + return venue.getSignerCount(); + } + public async findAffirmations( id: BigNumber, size: BigNumber, @@ -245,16 +253,6 @@ export class SettlementsService { return this.transactionsService.submit(instruction.executeManually, args, options); } - public async withdrawAffirmation( - id: BigNumber, - signerDto: TransactionBaseDto - ): ServiceReturn { - const { options } = extractTxOptions(signerDto); - const instruction = await this.findInstruction(id); - - return this.transactionsService.submit(instruction.withdraw, {}, options); - } - public async affirmInstructionAsMediator( id: BigNumber, transactionBaseDto: AffirmAsMediatorDto @@ -275,13 +273,35 @@ export class SettlementsService { return this.transactionsService.submit(instruction.rejectAsMediator, {}, options); } - public async withdrawAffirmationAsMediator( + public async lockInstructionForExecution( + id: BigNumber, + transactionBaseDto: TransactionBaseDto + ): ServiceReturn { + const { options } = extractTxOptions(transactionBaseDto); + const instruction = await this.findInstruction(id); + + return this.transactionsService.submit(instruction.lockForExecution, {}, options); + } + + public async unlockInstructionForExecution( id: BigNumber, - signerDto: TransactionBaseDto + transactionBaseDto: TransactionBaseDto ): ServiceReturn { - const { options } = extractTxOptions(signerDto); + const { options } = extractTxOptions(transactionBaseDto); + const instruction = await this.findInstruction(id); + + return this.transactionsService.submit(instruction.unlockForExecution, {}, options); + } + + public async getRelockStatus(id: BigNumber): Promise { + const instruction = await this.findInstruction(id); + + return instruction.getRelockStatus(); + } + + public async getLegStatus(id: BigNumber, legId: BigNumber): Promise { const instruction = await this.findInstruction(id); - return this.transactionsService.submit(instruction.withdrawAsMediator, {}, options); + return instruction.getLegStatus({ legId }); } } diff --git a/src/settlements/venues.controller.spec.ts b/src/settlements/venues.controller.spec.ts index 7f9ca8cb..a4cc0123 100644 --- a/src/settlements/venues.controller.spec.ts +++ b/src/settlements/venues.controller.spec.ts @@ -3,6 +3,7 @@ import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { VenueType } from '@polymeshassociation/polymesh-sdk/types'; import { when } from 'jest-when'; +import { SignerCountModel } from '~/settlements/models/signer-count.model'; import { SettlementsService } from '~/settlements/settlements.service'; import { VenuesController } from '~/settlements/venues.controller'; import { processedTxResult, testValues } from '~/test-utils/consts'; @@ -122,6 +123,16 @@ describe('VenuesController', () => { }); }); + describe('getSignerCount', () => { + it('should return the number of signers allowed by the Venue', async () => { + mockSettlementsService.fetchSignerCount.mockResolvedValue(new BigNumber(2)); + + const result = await controller.getSignerCount({ id: new BigNumber(3) }); + + expect(result).toEqual(new SignerCountModel({ count: new BigNumber(2) })); + }); + }); + describe('addVenueSigners', () => { it('should add signers to a venue and return the data returned by the service', async () => { mockSettlementsService.updateVenueSigners.mockResolvedValue(txResult); diff --git a/src/settlements/venues.controller.ts b/src/settlements/venues.controller.ts index e9d3a415..c2275561 100644 --- a/src/settlements/venues.controller.ts +++ b/src/settlements/venues.controller.ts @@ -13,6 +13,7 @@ import { ModifyVenueDto } from '~/settlements/dto/modify-venue.dto'; import { UpdateVenueSignersDto } from '~/settlements/dto/update-venue-signers.dto'; import { CreatedInstructionModel } from '~/settlements/models/created-instruction.model'; import { CreatedVenueModel } from '~/settlements/models/created-venue.model'; +import { SignerCountModel } from '~/settlements/models/signer-count.model'; import { VenueDetailsModel } from '~/settlements/models/venue-details.model'; import { SettlementsService } from '~/settlements/settlements.service'; import { legsToLegModel } from '~/settlements/settlements.util'; @@ -66,6 +67,26 @@ export class VenuesController { }); } + @ApiOperation({ + summary: 'Get the number of signers allowed by a Venue', + description: 'This endpoint returns the number of signers allowed by a Venue', + }) + @ApiParam({ + name: 'id', + description: 'The ID of the Venue whose signer count is being fetched', + type: 'string', + example: '123', + }) + @ApiOkResponse({ + description: 'The number of signers allowed by the Venue', + type: SignerCountModel, + }) + @Get('/:id/signer-count') + public async getSignerCount(@Param() { id }: IdParamsDto): Promise { + const count = await this.settlementsService.fetchSignerCount(id); + return new SignerCountModel({ count }); + } + @ApiOperation({ summary: 'Create a Venue', description: 'This endpoint creates a new Venue', diff --git a/src/signing/signer-management.controller.spec.ts b/src/signing/signer-management.controller.spec.ts new file mode 100644 index 00000000..6f6bed43 --- /dev/null +++ b/src/signing/signer-management.controller.spec.ts @@ -0,0 +1,38 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; + +import { SignerModel } from '~/signing/models/signer.model'; +import { SignerManagementController } from '~/signing/signer-management.controller'; +import { mockSigningProvider } from '~/signing/signing.mock'; +import { testValues } from '~/test-utils/consts'; + +describe('SignerManagementController', () => { + const signingService = mockSigningProvider.useValue; + const { + testAccount: { address }, + } = testValues; + let controller: SignerManagementController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SignerManagementController], + providers: [mockSigningProvider], + }).compile(); + controller = module.get(SignerManagementController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('addSigner', () => { + it('should call the service and return the result', () => { + const handle = 'test-handle'; + const mnemonic = 'test mnemonic phrase'; + const expectedResult = new SignerModel({ address }); + + when(signingService.addSigner).calledWith(handle, mnemonic).mockResolvedValue(address); + return expect(controller.addSigner({ handle, mnemonic })).resolves.toEqual(expectedResult); + }); + }); +}); diff --git a/src/signing/signer-management.controller.ts b/src/signing/signer-management.controller.ts new file mode 100644 index 00000000..2c059c31 --- /dev/null +++ b/src/signing/signer-management.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiBadRequestResponse, ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { AddLocalSignerDto } from '~/signing/dto/add-local-signer.dto'; +import { SignerModel } from '~/signing/models/signer.model'; +import { SigningService } from '~/signing/services'; + +/** + * @note only registered when `SIGNER_MANAGEMENT_ENABLED` resolves to true + */ +@ApiTags('signer') +@Controller('signer') +export class SignerManagementController { + constructor(private readonly signingService: SigningService) {} + + @ApiOperation({ + summary: 'Add a new signer', + description: 'Adds a new key to the signing manager', + }) + @ApiCreatedResponse({ + description: 'The signer was successfully added', + type: SignerModel, + }) + @ApiBadRequestResponse({ + description: 'Invalid mnemonic or handle provided', + }) + @Post() + public async addSigner(@Body() { handle, mnemonic }: AddLocalSignerDto): Promise { + const address = await this.signingService.addSigner(handle, mnemonic); + + return new SignerModel({ address }); + } +} diff --git a/src/signing/signer-management.module.ts b/src/signing/signer-management.module.ts new file mode 100644 index 00000000..80f76915 --- /dev/null +++ b/src/signing/signer-management.module.ts @@ -0,0 +1,29 @@ +/* istanbul ignore file */ + +import { DynamicModule, Logger, Module } from '@nestjs/common'; + +import { isSignerManagementEnabled } from '~/common/utils/feature-flags'; +import { SignerManagementController } from '~/signing/signer-management.controller'; +import { SigningModule } from '~/signing/signing.module'; + +/** + * Exposes the endpoints that add keys to the signing manager when `SIGNER_MANAGEMENT_ENABLED` resolves to true + */ +@Module({}) +export class SignerManagementModule { + static register(): DynamicModule { + const enabled = isSignerManagementEnabled(); + + if (enabled) { + new Logger(SignerManagementModule.name).warn( + 'Signer management endpoints are enabled. Make sure they are not reachable by untrusted callers' + ); + } + + return { + module: SignerManagementModule, + imports: [SigningModule], + controllers: enabled ? [SignerManagementController] : [], + }; + } +} diff --git a/src/signing/signing.controller.spec.ts b/src/signing/signing.controller.spec.ts index 6e6d2524..4c237510 100644 --- a/src/signing/signing.controller.spec.ts +++ b/src/signing/signing.controller.spec.ts @@ -33,15 +33,4 @@ describe('SigningController', () => { return expect(controller.getSignerAddress({ signer })).resolves.toEqual(expectedResult); }); }); - - describe('addSigner', () => { - it('should call the service and return the result', () => { - const handle = 'test-handle'; - const mnemonic = 'test mnemonic phrase'; - const expectedResult = new SignerModel({ address }); - - when(signingService.addSigner).calledWith(handle, mnemonic).mockResolvedValue(address); - return expect(controller.addSigner({ handle, mnemonic })).resolves.toEqual(expectedResult); - }); - }); }); diff --git a/src/signing/signing.controller.ts b/src/signing/signing.controller.ts index 09aa31f8..0aa3df38 100644 --- a/src/signing/signing.controller.ts +++ b/src/signing/signing.controller.ts @@ -1,7 +1,6 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Controller, Get, Param } from '@nestjs/common'; import { ApiBadRequestResponse, - ApiCreatedResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation, @@ -9,7 +8,6 @@ import { ApiTags, } from '@nestjs/swagger'; -import { AddLocalSignerDto } from '~/signing/dto/add-local-signer.dto'; import { SignerDetailsDto } from '~/signing/dto/signer-details.dto'; import { SignerModel } from '~/signing/models/signer.model'; import { SigningService } from '~/signing/services'; @@ -46,22 +44,4 @@ export class SigningController { return new SignerModel({ address }); } - - @ApiOperation({ - summary: 'Add a new signer', - description: 'Adds a new key to the signing manager', - }) - @ApiCreatedResponse({ - description: 'The signer was successfully added', - type: SignerModel, - }) - @ApiBadRequestResponse({ - description: 'Invalid mnemonic or handle provided', - }) - @Post() - public async addSigner(@Body() { handle, mnemonic }: AddLocalSignerDto): Promise { - const address = await this.signingService.addSigner(handle, mnemonic); - - return new SignerModel({ address }); - } } diff --git a/src/subsidy/subsidy.controller.spec.ts b/src/subsidy/subsidy.controller.spec.ts index b8ecb4d2..093baf19 100644 --- a/src/subsidy/subsidy.controller.spec.ts +++ b/src/subsidy/subsidy.controller.spec.ts @@ -4,8 +4,6 @@ import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { AllowanceOperation, TxTags } from '@polymeshassociation/polymesh-sdk/types'; import { when } from 'jest-when'; -import { createAuthorizationRequestModel } from '~/authorizations/authorizations.util'; -import { CreatedAuthorizationRequestModel } from '~/authorizations/models/created-authorization-request.model'; import { ProcessMode, TransactionType } from '~/common/types'; import { AcceptSubsidyDto } from '~/subsidy/dto/accept-subsidy.dto'; import { CreateSubsidyDto } from '~/subsidy/dto/create-subsidy.dto'; @@ -15,7 +13,7 @@ import { RevokeSubsidyDto } from '~/subsidy/dto/revoke-subsidy.dto'; import { SubsidyController } from '~/subsidy/subsidy.controller'; import { SubsidyService } from '~/subsidy/subsidy.service'; import { processedTxResult, txResult } from '~/test-utils/consts'; -import { createMockTransactionResult, MockAuthorizationRequest } from '~/test-utils/mocks'; +import { createMockTransactionResult } from '~/test-utils/mocks'; import { mockSubsidyServiceProvider } from '~/test-utils/service-mocks'; describe('SubsidyController', () => { @@ -62,45 +60,6 @@ describe('SubsidyController', () => { }); }); - describe('subsidizeAccount', () => { - it('should accept CreateSubsidyDto and return the authorization request for adding as paying key', async () => { - const transaction = { - blockHash: '0x1', - transactionHash: '0x2', - blockNumber: new BigNumber(1), - type: TransactionType.Single, - transactionTag: TxTags.relayer.AcceptPayingKey, - }; - const mockAuthorization = new MockAuthorizationRequest(); - const testTxResult = createMockTransactionResult({ - ...txResult, - transactions: [transaction], - result: mockAuthorization, - }); - const mockPayload: CreateSubsidyDto = { - signer: 'Alice', - beneficiary, - allowance, - }; - - when(mockService.subsidizeAccount) - .calledWith(mockPayload) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .mockResolvedValue(testTxResult as any); - - const result = await controller.subsidizeAccount(mockPayload); - - expect(result).toEqual( - new CreatedAuthorizationRequestModel({ - ...processedTxResult, - transactions: [transaction], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - authorizationRequest: createAuthorizationRequestModel(mockAuthorization as any), - }) - ); - }); - }); - describe('approveSubsidy', () => { it('should accept CreateSubsidyDto and return transaction details', async () => { const mockPayload: CreateSubsidyDto = { diff --git a/src/subsidy/subsidy.controller.ts b/src/subsidy/subsidy.controller.ts index db65b90f..fc60fba9 100644 --- a/src/subsidy/subsidy.controller.ts +++ b/src/subsidy/subsidy.controller.ts @@ -8,8 +8,6 @@ import { } from '@nestjs/swagger'; import { AllowanceOperation } from '@polymeshassociation/polymesh-sdk/types'; -import { authorizationRequestResolver } from '~/authorizations/authorizations.util'; -import { CreatedAuthorizationRequestModel } from '~/authorizations/models/created-authorization-request.model'; import { ApiTransactionFailedResponse, ApiTransactionResponse } from '~/common/decorators/'; import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; import { handleServiceResult, TransactionResponseModel } from '~/common/utils'; @@ -63,32 +61,9 @@ export class SubsidyController { }); } - @ApiOperation({ - summary: 'Subsidize an account', - description: - 'This endpoint sends an Authorization Request to an Account to subsidize its transaction fees. Deprecated on chain v8; use approve and accept instead', - deprecated: true, - }) - @ApiTransactionResponse({ - description: 'Newly created Authorization Request along with transaction details', - type: CreatedAuthorizationRequestModel, - }) - @ApiTransactionFailedResponse({ - [HttpStatus.BAD_REQUEST]: [ - 'The Beneficiary Account already has a pending invitation to add this account as a subsidizer with the same allowance', - ], - }) - @Post('create') - async subsidizeAccount(@Body() params: CreateSubsidyDto): Promise { - const serviceResult = await this.subsidyService.subsidizeAccount(params); - - return handleServiceResult(serviceResult, authorizationRequestResolver); - } - @ApiOperation({ summary: 'Approve a subsidy for a beneficiary account', - description: - 'This endpoint allows a subsidizer to approve an allowance for a beneficiary account on chain v8', + description: 'This endpoint allows a subsidizer to approve an allowance for a beneficiary', }) @ApiTransactionResponse({ description: 'Details about the transaction', diff --git a/src/subsidy/subsidy.service.spec.ts b/src/subsidy/subsidy.service.spec.ts index 9a03100b..c1cd44ee 100644 --- a/src/subsidy/subsidy.service.spec.ts +++ b/src/subsidy/subsidy.service.spec.ts @@ -16,13 +16,7 @@ import { ModifyAllowanceDto } from '~/subsidy/dto/modify-allowance.dto'; import { QuitSubsidyDto } from '~/subsidy/dto/quit-subsidy.dto'; import { SubsidyService } from '~/subsidy/subsidy.service'; import { testValues } from '~/test-utils/consts'; -import { - createMockSubsidy, - MockAccount, - MockAuthorizationRequest, - MockPolymesh, - MockTransaction, -} from '~/test-utils/mocks'; +import { createMockSubsidy, MockAccount, MockPolymesh, MockTransaction } from '~/test-utils/mocks'; import { MockAccountsService, mockTransactionsProvider, @@ -117,42 +111,6 @@ describe('SubsidyService', () => { }); }); - describe('subsidizeAccount', () => { - it('should run a subsidizeAccount procedure and return the queue results', async () => { - const mockAuthRequest = new MockAuthorizationRequest(); - const mockTransactions = { - blockHash: '0x1', - txHash: '0x2', - blockNumber: new BigNumber(1), - tag: TxTags.relayer.SetPayingKey, - }; - const mockTransaction = new MockTransaction(mockTransactions); - - mockTransactionsService.submit.mockResolvedValue({ - result: mockAuthRequest, - transactions: [mockTransaction], - }); - - const body = { - signer, - beneficiary, - allowance: new BigNumber(100), - }; - - const result = await service.subsidizeAccount(body); - expect(result).toEqual({ - result: mockAuthRequest, - transactions: [mockTransaction], - }); - - expect(mockTransactionsService.submit).toHaveBeenCalledWith( - mockPolymeshApi.accountManagement.subsidizeAccount, - { beneficiary, allowance }, - expect.objectContaining({ signer }) - ); - }); - }); - describe('quit', () => { it('should run a quit procedure and return the queue results', async () => { const mockTransactions = { diff --git a/src/subsidy/subsidy.service.ts b/src/subsidy/subsidy.service.ts index 19569910..a6393b4d 100644 --- a/src/subsidy/subsidy.service.ts +++ b/src/subsidy/subsidy.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { AllowanceOperation, - AuthorizationRequest, Subsidy, SubsidyWithAllowance, } from '@polymeshassociation/polymesh-sdk/types'; @@ -40,14 +39,6 @@ export class SubsidyService { }); } - public async subsidizeAccount(params: CreateSubsidyDto): ServiceReturn { - const { options, args } = extractTxOptions(params); - - const { subsidizeAccount } = this.polymeshService.polymeshApi.accountManagement; - - return this.transactionsService.submit(subsidizeAccount, args, options); - } - public async approveSubsidy(params: CreateSubsidyDto): ServiceReturn { const { options, args } = extractTxOptions(params); diff --git a/src/test-utils/mocks.ts b/src/test-utils/mocks.ts index 7e383e01..03e21790 100644 --- a/src/test-utils/mocks.ts +++ b/src/test-utils/mocks.ts @@ -97,6 +97,7 @@ export class MockPolymesh { transferFunds: jest.fn(), getTickerReservation: jest.fn(), getTickerReservations: jest.fn(), + getTickerRegistrationConfig: jest.fn(), getGlobalMetadataKeys: jest.fn(), }; @@ -108,7 +109,6 @@ export class MockPolymesh { unfreezeSecondaryAccounts: jest.fn(), revokePermissions: jest.fn(), modifyPermissions: jest.fn(), - subsidizeAccount: jest.fn(), approveSubsidy: jest.fn(), acceptSubsidy: jest.fn(), revokeSubsidy: jest.fn(), @@ -137,7 +137,6 @@ export class MockPolymesh { addClaims: jest.fn(), editClaims: jest.fn(), revokeClaims: jest.fn(), - getCddClaims: jest.fn(), getClaimScopes: jest.fn(), addInvestorUniquenessClaim: jest.fn(), getInvestorUniquenessClaims: jest.fn(), @@ -154,7 +153,7 @@ export class MockPolymesh { transferWithMemo: jest.fn(), setBalance: jest.fn(), }, - cddServiceProviders: { + didRegistrars: { addMember: jest.fn(), }, identity: { @@ -197,6 +196,7 @@ export class MockAsset { public getRequiredMediators = jest.fn(); public addRequiredMediators = jest.fn(); public removeRequiredMediators = jest.fn(); + public getIssuedInFundingRound = jest.fn(); public linkTicker = jest.fn(); public unlinkTicker = jest.fn(); @@ -244,6 +244,7 @@ export class MockAsset { create: jest.fn(), remove: jest.fn(), maxComplexity: jest.fn(), + getNextCheckpoint: jest.fn(), }, }; @@ -298,12 +299,14 @@ export class MockInstruction { public getAffirmations = jest.fn(); public getOffChainAffirmations = jest.fn(); public getOffChainAffirmationForLeg = jest.fn(); - public withdraw = jest.fn(); public reschedule = jest.fn(); public getMediators = jest.fn(); public affirmAsMediator = jest.fn(); public rejectAsMediator = jest.fn(); - public withdrawAsMediator = jest.fn(); + public lockForExecution = jest.fn(); + public unlockForExecution = jest.fn(); + public getRelockStatus = jest.fn(); + public getLegStatus = jest.fn(); public generateOffChainAffirmationReceipt = jest.fn(); public toHuman = jest.fn().mockImplementation(() => { return { @@ -321,6 +324,7 @@ export class MockVenue { public getAllowedSigners = jest.fn(); public addSigners = jest.fn(); public removeSigners = jest.fn(); + public getSignerCount = jest.fn(); } export class MockIdentityAuthorization { @@ -366,6 +370,10 @@ export class MockPortfolio { public moveFunds = jest.fn(); public getTransactionHistory = jest.fn(); public quitCustody = jest.fn(); + public preApproveAsset = jest.fn(); + public removeAssetPreApproval = jest.fn(); + public isAssetPreApproved = jest.fn(); + public preApprovedAssets = jest.fn(); public toHuman = jest.fn().mockImplementation(() => { return { id: '1', diff --git a/src/test-utils/service-mocks.ts b/src/test-utils/service-mocks.ts index 41d9e0d5..415a51d3 100644 --- a/src/test-utils/service-mocks.ts +++ b/src/test-utils/service-mocks.ts @@ -49,6 +49,7 @@ export class MockAssetService { getRequiredMediators = jest.fn(); addRequiredMediators = jest.fn(); removeRequiredMediators = jest.fn(); + getIssuedInFundingRound = jest.fn(); preApprove = jest.fn(); removePreApproval = jest.fn(); linkTickerToAsset = jest.fn(); @@ -106,6 +107,7 @@ export class MockTickerReservationsService { transferOwnership = jest.fn(); extend = jest.fn(); findAllByOwner = jest.fn(); + getTickerRegistrationConfig = jest.fn(); } export class MockAuthorizationsService { @@ -171,6 +173,7 @@ export class MockIdentitiesService { addSecondaryAccount = jest.fn(); createMockCdd = jest.fn(); registerDid = jest.fn(); + registerDidAsRegistrar = jest.fn(); selfRegisterDid = jest.fn(); setMandatoryReceiverAffirmation = jest.fn(); rotatePrimaryKey = jest.fn(); @@ -195,20 +198,22 @@ export class MockSettlementsService { canTransfer = jest.fn(); findGroupedInstructionsByDid = jest.fn(); findVenuesByOwner = jest.fn(); - withdrawAffirmation = jest.fn(); rescheduleInstruction = jest.fn(); affirmInstructionAsMediator = jest.fn(); rejectInstructionAsMediator = jest.fn(); - withdrawAffirmationAsMediator = jest.fn(); executeInstruction = jest.fn(); fetchAllowedSigners = jest.fn(); + fetchSignerCount = jest.fn(); updateVenueSigners = jest.fn(); + lockInstructionForExecution = jest.fn(); + unlockInstructionForExecution = jest.fn(); + getRelockStatus = jest.fn(); + getLegStatus = jest.fn(); } export class MockClaimsService { findIssuedByDid = jest.fn(); findAssociatedByDid = jest.fn(); - findCddClaimsByDid = jest.fn(); } export class MockPortfoliosService { @@ -223,6 +228,10 @@ export class MockPortfoliosService { createdAt = jest.fn(); setCustodian = jest.fn(); quitCustody = jest.fn(); + preApproveAsset = jest.fn(); + removeAssetPreApproval = jest.fn(); + isAssetPreApproved = jest.fn(); + getPreApprovedAssets = jest.fn(); } export class MockOfferingsService { @@ -240,6 +249,7 @@ export class MockCorporateActionsService { payDividends = jest.fn(); claimDividends = jest.fn(); linkDocuments = jest.fn(); + getDocuments = jest.fn(); reclaimRemainingFunds = jest.fn(); modifyCheckpoint = jest.fn(); } @@ -248,6 +258,7 @@ export class MockCheckpointsService { findAllByAsset = jest.fn(); findSchedulesByAsset = jest.fn(); findScheduleById = jest.fn(); + getNextCheckpoint = jest.fn(); createByAsset = jest.fn(); createScheduleByAsset = jest.fn(); getAssetBalance = jest.fn(); diff --git a/src/ticker-reservations/models/ticker-registration-config.model.spec.ts b/src/ticker-reservations/models/ticker-registration-config.model.spec.ts new file mode 100644 index 00000000..78239450 --- /dev/null +++ b/src/ticker-reservations/models/ticker-registration-config.model.spec.ts @@ -0,0 +1,32 @@ +/* istanbul ignore file */ + +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { instanceToPlain } from 'class-transformer'; + +import { TickerRegistrationConfigModel } from '~/ticker-reservations/models/ticker-registration-config.model'; + +describe('TickerRegistrationConfigModel', () => { + describe('serialization', () => { + it('should serialize registrationLength as null, not drop the field, when reservations never expire', () => { + const model = new TickerRegistrationConfigModel({ + maxTickerLength: new BigNumber(12), + registrationLength: null, + }); + + const plain = instanceToPlain(model); + + expect(plain).toHaveProperty('registrationLength', null); + }); + + it('should serialize registrationLength as a string when set', () => { + const model = new TickerRegistrationConfigModel({ + maxTickerLength: new BigNumber(12), + registrationLength: new BigNumber(5184000000), + }); + + const plain = instanceToPlain(model); + + expect(plain.registrationLength).toBe('5184000000'); + }); + }); +}); diff --git a/src/ticker-reservations/models/ticker-registration-config.model.ts b/src/ticker-reservations/models/ticker-registration-config.model.ts new file mode 100644 index 00000000..b5838468 --- /dev/null +++ b/src/ticker-reservations/models/ticker-registration-config.model.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { Transform } from 'class-transformer'; + +import { FromBigNumber } from '~/common/decorators/transformation'; + +export class TickerRegistrationConfigModel { + @ApiProperty({ + description: 'Maximum allowed length for a ticker', + type: 'string', + example: '12', + }) + @FromBigNumber() + readonly maxTickerLength: BigNumber; + + @ApiPropertyOptional({ + description: + 'Amount of time (in milliseconds) a ticker reservation is valid for before it expires, starting from the moment it is reserved. Null if ticker reservations never expire', + type: 'string', + nullable: true, + example: '5184000000', + }) + @Transform(({ value }: { value: BigNumber | null }) => (value ? value.toString() : null)) + readonly registrationLength: BigNumber | null; + + constructor(model: TickerRegistrationConfigModel) { + Object.assign(this, model); + } +} diff --git a/src/ticker-reservations/ticker-reservations.controller.spec.ts b/src/ticker-reservations/ticker-reservations.controller.spec.ts index f961147b..a5572f9d 100644 --- a/src/ticker-reservations/ticker-reservations.controller.spec.ts +++ b/src/ticker-reservations/ticker-reservations.controller.spec.ts @@ -1,10 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { TickerReservationStatus } from '@polymeshassociation/polymesh-sdk/types'; import { createAuthorizationRequestModel } from '~/authorizations/authorizations.util'; import { processedTxResult, testValues } from '~/test-utils/consts'; import { MockAuthorizationRequest, MockIdentity, MockTickerReservation } from '~/test-utils/mocks'; import { MockTickerReservationsService } from '~/test-utils/service-mocks'; +import { TickerRegistrationConfigModel } from '~/ticker-reservations/models/ticker-registration-config.model'; import { TickerReservationsController } from '~/ticker-reservations/ticker-reservations.controller'; import { TickerReservationsService } from '~/ticker-reservations/ticker-reservations.service'; @@ -41,6 +43,20 @@ describe('TickerReservationsController', () => { }); }); + describe('getTickerRegistrationConfig', () => { + it('should call the service and return the configuration', async () => { + const mockConfig = { + maxTickerLength: new BigNumber(12), + registrationLength: new BigNumber(5184000000), + }; + mockTickerReservationsService.getTickerRegistrationConfig.mockResolvedValue(mockConfig); + + const result = await controller.getTickerRegistrationConfig(); + + expect(result).toEqual(new TickerRegistrationConfigModel(mockConfig)); + }); + }); + describe('getDetails', () => { it('should call the service and return the details', async () => { const mockDetails = { diff --git a/src/ticker-reservations/ticker-reservations.controller.ts b/src/ticker-reservations/ticker-reservations.controller.ts index 5f57abd0..b62160d4 100644 --- a/src/ticker-reservations/ticker-reservations.controller.ts +++ b/src/ticker-reservations/ticker-reservations.controller.ts @@ -18,6 +18,7 @@ import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; import { handleServiceResult, TransactionResolver, TransactionResponseModel } from '~/common/utils'; import { ReserveTickerDto } from '~/ticker-reservations/dto/reserve-ticker.dto'; import { ExtendedTickerReservationModel } from '~/ticker-reservations/models/extended-ticker-reservation.model'; +import { TickerRegistrationConfigModel } from '~/ticker-reservations/models/ticker-registration-config.model'; import { TickerReservationModel } from '~/ticker-reservations/models/ticker-reservation.model'; import { TickerReservationsService } from '~/ticker-reservations/ticker-reservations.service'; import { createTickerReservationModel } from '~/ticker-reservations/ticker-reservations.util'; @@ -47,6 +48,20 @@ export class TickerReservationsController { return handleServiceResult(result); } + @ApiOperation({ + summary: 'Get ticker registration configuration', + description: 'This endpoint returns the chain-wide rules used to validate ticker registrations', + }) + @ApiOkResponse({ + description: 'Chain-wide ticker registration configuration', + type: TickerRegistrationConfigModel, + }) + @Get('config') + public async getTickerRegistrationConfig(): Promise { + const config = await this.tickerReservationsService.getTickerRegistrationConfig(); + return new TickerRegistrationConfigModel(config); + } + @ApiOperation({ summary: 'Get ticker reservation details', description: 'This endpoint returns details of ticker reservation', diff --git a/src/ticker-reservations/ticker-reservations.service.spec.ts b/src/ticker-reservations/ticker-reservations.service.spec.ts index 833f55ff..b623a79a 100644 --- a/src/ticker-reservations/ticker-reservations.service.spec.ts +++ b/src/ticker-reservations/ticker-reservations.service.spec.ts @@ -182,4 +182,17 @@ describe('TickerReservationsService', () => { expect(result).toEqual([mockTickerReservation]); }); }); + + describe('getTickerRegistrationConfig', () => { + it('should return the chain-wide ticker registration configuration', async () => { + const mockConfig = { + maxTickerLength: new BigNumber(12), + registrationLength: new BigNumber(5184000000), + }; + mockPolymeshApi.assets.getTickerRegistrationConfig.mockResolvedValue(mockConfig); + + const result = await service.getTickerRegistrationConfig(); + expect(result).toEqual(mockConfig); + }); + }); }); diff --git a/src/ticker-reservations/ticker-reservations.service.ts b/src/ticker-reservations/ticker-reservations.service.ts index c1e287cb..bf585f75 100644 --- a/src/ticker-reservations/ticker-reservations.service.ts +++ b/src/ticker-reservations/ticker-reservations.service.ts @@ -1,5 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { AuthorizationRequest, TickerReservation } from '@polymeshassociation/polymesh-sdk/types'; +import { + AuthorizationRequest, + TickerRegistrationConfig, + TickerReservation, +} from '@polymeshassociation/polymesh-sdk/types'; import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; import { TransferOwnershipDto } from '~/common/dto/transfer-ownership.dto'; @@ -57,4 +61,11 @@ export class TickerReservationsService { } = this; return polymeshApi.assets.getTickerReservations({ owner }); } + + public async getTickerRegistrationConfig(): Promise { + const { + polymeshService: { polymeshApi }, + } = this; + return polymeshApi.assets.getTickerRegistrationConfig(); + } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index b53ef7a7..05b95b31 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,16 +1,44 @@ -import { Module } from '@nestjs/common'; +/* istanbul ignore file */ +import { DynamicModule, Logger, Module } from '@nestjs/common'; + +import { isAuthManagementEnabled } from '~/common/utils/feature-flags'; import { DatastoreModule } from '~/datastore/datastore.module'; import { UsersController } from '~/users/users.controller'; import { UsersService } from '~/users/users.service'; /** * responsible for the REST API's users + * + * @note `UsersController` is only registered when `AUTH_MANAGEMENT_ENABLED` resolves to true */ -@Module({ - imports: [DatastoreModule.registerAsync()], - providers: [UsersService], - exports: [UsersService], - controllers: [UsersController], -}) -export class UsersModule {} +@Module({}) +export class UsersModule { + private static dynamicModule?: DynamicModule; + + /** + * @note memoized so every importer shares one module instance (Nest identifies dynamic modules by reference). + * Must only be called after `ConfigModule.forRoot` has loaded the environment, i.e. not at file import time + */ + static register(): DynamicModule { + if (!UsersModule.dynamicModule) { + const enabled = isAuthManagementEnabled(); + + if (enabled) { + new Logger(UsersModule.name).warn( + 'User management endpoints are enabled. Make sure they are not reachable by untrusted callers' + ); + } + + UsersModule.dynamicModule = { + module: UsersModule, + imports: [DatastoreModule.registerAsync()], + controllers: enabled ? [UsersController] : [], + providers: [UsersService], + exports: [UsersService], + }; + } + + return UsersModule.dynamicModule; + } +} diff --git a/yarn.lock b/yarn.lock index c8e35006..056d8030 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2797,17 +2797,16 @@ __metadata: languageName: node linkType: hard -"@polymeshassociation/polymesh-sdk@npm:v30.0.0": - version: 30.0.0 - resolution: "@polymeshassociation/polymesh-sdk@npm:30.0.0" +"@polymeshassociation/polymesh-sdk@npm:31.1.0-beta.8": + version: 31.1.0-beta.8 + resolution: "@polymeshassociation/polymesh-sdk@npm:31.1.0-beta.8" dependencies: "@apollo/client": "npm:^3.8.1" "@polkadot/api": "npm:16.5.2" "@polkadot/util": "npm:13.5.9" "@polkadot/util-crypto": "npm:13.5.9" - "@polymeshassociation/polymesh-types": "npm:^7.4.0" + "@polymeshassociation/polymesh-types": "npm:^7.5.0" bignumber.js: "npm:9.0.1" - cross-fetch: "npm:^4.0.0" dayjs: "npm:1.11.9" graphql: "npm:^16.8.0" graphql-tag: "npm:2.12.6" @@ -2817,20 +2816,20 @@ __metadata: semver: "npm:^7.5.4" ts-morph: "npm:^25.0.1" ws: "npm:^8.18.3" - checksum: 10c0/c1689fc9ef7632d9f7743dce0e4250f8a7005e161fac034b3d0f02530dea50152a9b21fd5e6bb08d5f5bd2ace22494b9194ba0129d36c966c9310673c120f4e5 + checksum: 10c0/1fb2f04afb80a2c97b0f7143a5e957ae6ed1fc3f14657b81af255092041aba66fb25c833a5acaf0cbeaa71badc587ed6037a52aeef10a0cee62f3c4bdda8216b languageName: node linkType: hard -"@polymeshassociation/polymesh-types@npm:^7.4.0": - version: 7.4.0 - resolution: "@polymeshassociation/polymesh-types@npm:7.4.0" +"@polymeshassociation/polymesh-types@npm:^7.5.0": + version: 7.5.0 + resolution: "@polymeshassociation/polymesh-types@npm:7.5.0" dependencies: "@polkadot/api": "npm:16.5.2" "@polkadot/api-base": "npm:16.5.2" "@polkadot/rpc-core": "npm:16.5.2" "@polkadot/types": "npm:16.5.2" "@polkadot/types-codec": "npm:16.5.2" - checksum: 10c0/e0ba7d34eee7171f8ed02dfe47295fdbb55bcc4b315d327a724312242fcea3b485064f64a453c9f13cdd1b75197626714482a0e16fe15274223bd3f150c554b8 + checksum: 10c0/cdb8191954e864e5eee65aca83205b6e2e002c7bcf78b6bc6a24d101bc09e0d904d7015a08dd3734cdead839bc457b9acb884d5799fae73a52ab4de131d7c03c languageName: node linkType: hard @@ -11409,8 +11408,8 @@ __metadata: "@polymeshassociation/fireblocks-signing-manager": "npm:^3.0.0" "@polymeshassociation/hashicorp-vault-signing-manager": "npm:^4.1.0" "@polymeshassociation/local-signing-manager": "npm:^4.1.1" - "@polymeshassociation/polymesh-sdk": "npm:v30.0.0" - "@polymeshassociation/polymesh-types": "npm:^7.4.0" + "@polymeshassociation/polymesh-sdk": "npm:31.1.0-beta.8" + "@polymeshassociation/polymesh-types": "npm:^7.5.0" "@polymeshassociation/signing-manager-types": "npm:^3.7.1" "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/exec": "npm:^7.1.0"