diff --git a/app/main.py b/app/main.py index 6f0d997f..d15255f8 100644 --- a/app/main.py +++ b/app/main.py @@ -45,6 +45,7 @@ position, settlement_issuer, share, + token_common, token_holders, ) from app.routers.misc import ( @@ -148,6 +149,7 @@ async def root(): app.include_router(notification.router) app.include_router(position.router) app.include_router(share.router) + app.include_router(token_common.router) app.include_router(token_holders.router) app.include_router(settlement_issuer.router) app.include_router(sealed_tx.router) diff --git a/app/model/schema/__init__.py b/app/model/schema/__init__.py index 21831189..7d8498e2 100644 --- a/app/model/schema/__init__.py +++ b/app/model/schema/__init__.py @@ -171,6 +171,8 @@ ListAllAdditionalIssueUploadQuery, ListAllHoldersQuery, ListAllHoldersSortItem, + ListAllIssuedTokensQuery, + ListAllIssuedTokensResponse, ListAllRedeemUploadQuery, ListAllTokenLockEventsQuery, ListAllTokenLockEventsResponse, diff --git a/app/model/schema/token.py b/app/model/schema/token.py index 45be4cf2..34b06804 100644 --- a/app/model/schema/token.py +++ b/app/model/schema/token.py @@ -39,6 +39,7 @@ MMDD_constr, ResultSet, SortOrder, + TokenType, ValueOperator, YYYYMMDD_constr, ) @@ -308,6 +309,27 @@ class IbetShareRedeem(BaseModel): amount: int = Field(..., ge=1, le=1_000_000_000_000) +class ListAllIssuedTokensSortItem(StrEnum): + CREATED = "created" + TOKEN_ADDRESS = "token_address" + + +class ListAllIssuedTokensQuery(BasePaginationQuery): + """ListAllIssuedTokens query parameters""" + + token_address_list: Optional[list[EthereumAddress]] = Field( + None, description="Token address to filter (**this affects total number**)" + ) + token_type: Optional[TokenType] = Field(None, description="Token type") + + sort_item: Optional[ListAllIssuedTokensSortItem] = Field( + ListAllIssuedTokensSortItem.CREATED + ) + sort_order: Optional[SortOrder] = Field( + SortOrder.DESC, description=SortOrder.__doc__ + ) + + class IssueRedeemSortItem(StrEnum): """Issue/Redeem sort item""" @@ -461,6 +483,27 @@ class ListTokenOperationLogHistoryQuery(BasePaginationQuery): ############################ # RESPONSE ############################ +class IssuedToken(BaseModel): + """Issued Token""" + + issuer_address: str = Field(description="Issuer address") + token_address: str = Field(description="Token address") + token_type: TokenType = Field(description="Token type") + created: str = Field(description="Created(Issued) datetime") + token_status: Optional[int] = Field(description="Token status") + contract_version: str = Field(description="Contract version") + token_attributes: IbetStraightBond | IbetShare = Field( + description="Token attributes" + ) + + +class ListAllIssuedTokensResponse(BaseModel): + """List issued tokens schema""" + + result_set: ResultSet + tokens: list[IssuedToken] + + class TokenAddressResponse(BaseModel): """token address""" diff --git a/app/routers/issuer/token_common.py b/app/routers/issuer/token_common.py new file mode 100644 index 00000000..046bc54f --- /dev/null +++ b/app/routers/issuer/token_common.py @@ -0,0 +1,135 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" + +from typing import Annotated, Optional + +import pytz +from fastapi import APIRouter, Header, Query +from sqlalchemy import asc, desc, func, select + +import config +from app.database import DBAsyncSession +from app.model.blockchain import IbetShareContract, IbetStraightBondContract +from app.model.db import Token, TokenType +from app.model.schema import ListAllIssuedTokensQuery, ListAllIssuedTokensResponse +from app.utils.check_utils import address_is_valid_address, validate_headers +from app.utils.docs_utils import get_routers_responses +from app.utils.fastapi_utils import json_response + +router = APIRouter( + prefix="", + tags=["token_common"], +) + +local_tz = pytz.timezone(config.TZ) +utc_tz = pytz.timezone("UTC") + + +# GET: /tokens +@router.get( + "/tokens", + operation_id="ListAllIssuedTokens", + response_model=ListAllIssuedTokensResponse, + responses=get_routers_responses(422), +) +async def list_all_issued_tokens( + db: DBAsyncSession, + request_query: Annotated[ListAllIssuedTokensQuery, Query()], + issuer_address: Annotated[Optional[str], Header()] = None, +): + """List all tokens issued from ibet-Prime""" + + # Validate Headers + validate_headers(issuer_address=(issuer_address, address_is_valid_address)) + + # Base Query + if issuer_address is None: + stmt = select(Token) + else: + stmt = select(Token).where(Token.issuer_address == issuer_address) + + if request_query.token_address_list is not None: + stmt = stmt.where(Token.token_address.in_(request_query.token_address_list)) + + total = await db.scalar(select(func.count()).select_from(stmt.subquery())) + + # Search Filter + if request_query.token_type is not None: + stmt = stmt.where(Token.type == request_query.token_type) + + count = await db.scalar(select(func.count()).select_from(stmt.subquery())) + + # Sort + sort_attr = getattr(Token, request_query.sort_item, None) + if request_query.sort_order == 0: # ASC + stmt = stmt.order_by(asc(sort_attr)) + else: # DESC + stmt = stmt.order_by(desc(sort_attr)) + + if request_query.sort_item != "created": + # NOTE: Set secondary sort for consistent results + stmt = stmt.order_by(desc(Token.created)) + + # Pagination + if request_query.limit is not None: + stmt = stmt.limit(request_query.limit) + if request_query.offset is not None: + stmt = stmt.offset(request_query.offset) + + # Execute Query + issued_tokens = (await db.scalars(stmt)).all() + + # Get Token Attributes + tokens = [] + for _token in issued_tokens: + token_attr = None + if _token.type == TokenType.IBET_STRAIGHT_BOND: + token_attr = await IbetStraightBondContract(_token.token_address).get() + elif _token.type == TokenType.IBET_SHARE: + token_attr = await IbetShareContract(_token.token_address).get() + + _issue_datetime = ( + pytz.timezone("UTC") + .localize(_token.created) + .astimezone(local_tz) + .isoformat() + ) + + tokens.append( + { + "issuer_address": _token.issuer_address, + "token_address": _token.token_address, + "token_type": _token.type, + "created": _issue_datetime, + "token_status": _token.token_status, + "contract_version": _token.version, + "token_attributes": token_attr.__dict__, + } + ) + + resp = { + "result_set": { + "count": count, + "offset": request_query.offset, + "limit": request_query.limit, + "total": total, + }, + "tokens": tokens, + } + return json_response(resp) diff --git a/docs/ibet_prime.yaml b/docs/ibet_prime.yaml index 2493e261..5d240937 100644 --- a/docs/ibet_prime.yaml +++ b/docs/ibet_prime.yaml @@ -8217,6 +8217,99 @@ paths: application/json: schema: $ref: '#/components/schemas/Error404Model' + /tokens: + get: + tags: + - token_common + summary: List All Issued Tokens + description: List all tokens issued from ibet-Prime + operationId: ListAllIssuedTokens + parameters: + - name: offset + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + description: Offset for pagination + title: Offset + description: Offset for pagination + - name: limit + in: query + required: false + schema: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + description: Limit for pagination + title: Limit + description: Limit for pagination + - name: token_address_list + in: query + required: false + schema: + anyOf: + - type: array + items: + type: string + - type: 'null' + description: Token address to filter (**this affects total number**) + title: Token Address List + description: Token address to filter (**this affects total number**) + - name: token_type + in: query + required: false + schema: + anyOf: + - $ref: '#/components/schemas/TokenType' + - type: 'null' + description: Token type + title: Token Type + description: Token type + - name: sort_item + in: query + required: false + schema: + anyOf: + - $ref: '#/components/schemas/ListAllIssuedTokensSortItem' + - type: 'null' + default: created + title: Sort Item + - name: sort_order + in: query + required: false + schema: + anyOf: + - $ref: '#/components/schemas/SortOrder' + - type: 'null' + description: 'Sort order (0: ASC, 1: DESC)' + default: 1 + title: Sort Order + description: 'Sort order (0: ASC, 1: DESC)' + - name: issuer-address + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Issuer-Address + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListAllIssuedTokensResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/Error422Model' /token/holders/personal_info: get: tags: @@ -13544,6 +13637,50 @@ components: - amount title: IssueRedeemSortItem description: Issue/Redeem sort item + IssuedToken: + properties: + issuer_address: + type: string + title: Issuer Address + description: Issuer address + token_address: + type: string + title: Token Address + description: Token address + token_type: + $ref: '#/components/schemas/TokenType' + description: Token type + created: + type: string + title: Created + description: Created(Issued) datetime + token_status: + anyOf: + - type: integer + - type: 'null' + title: Token Status + description: Token status + contract_version: + type: string + title: Contract Version + description: Contract version + token_attributes: + anyOf: + - $ref: '#/components/schemas/IbetStraightBond' + - $ref: '#/components/schemas/IbetShare' + title: Token Attributes + description: Token attributes + type: object + required: + - issuer_address + - token_address + - token_type + - created + - token_status + - contract_version + - token_attributes + title: IssuedToken + description: Issued Token KeyManagerType: type: string enum: @@ -13791,6 +13928,27 @@ components: - key_manager - holder_name title: ListAllHoldersSortItem + ListAllIssuedTokensResponse: + properties: + result_set: + $ref: '#/components/schemas/ResultSet' + tokens: + items: + $ref: '#/components/schemas/IssuedToken' + type: array + title: Tokens + type: object + required: + - result_set + - tokens + title: ListAllIssuedTokensResponse + description: List issued tokens schema + ListAllIssuedTokensSortItem: + type: string + enum: + - created + - token_address + title: ListAllIssuedTokensSortItem ListAllLedgerDetailsDataResponse: properties: result_set: diff --git a/tests/app/test_token_common_ListAllIssuedTokens.py b/tests/app/test_token_common_ListAllIssuedTokens.py new file mode 100644 index 00000000..184f8d3a --- /dev/null +++ b/tests/app/test_token_common_ListAllIssuedTokens.py @@ -0,0 +1,1107 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" + +from unittest import mock + +import pytest + +from app.model.blockchain import IbetShareContract, IbetStraightBondContract +from app.model.db import Token, TokenType, TokenVersion + + +class TestListAllIssuedTokens: + # API endpoint + api_url = "/tokens" + + issuer_address_1 = "0x1234567890123456789012345678900000000100" + issuer_address_2 = "0x1234567890123456789012345678900000000200" + issuer_address_3 = "0x1234567890123456789012345678900000000300" + + token_address_1 = "0x1234567890123456789012345678900000000010" + token_address_2 = "0x1234567890123456789012345678900000000020" + token_address_3 = "0x1234567890123456789012345678900000000030" + + ########################################################################### + # Normal + ########################################################################### + + # + # 0 record + def test_normal_1(self, client, db): + # Request target API + resp = client.get(self.api_url) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 0, + "offset": None, + "limit": None, + "total": 0, + }, + "tokens": [], + } + + # + # TokenType = IbetStraightBond + @pytest.mark.freeze_time("2025-01-31 12:34:56") + @mock.patch("app.model.blockchain.token.IbetStraightBondContract.get") + def test_normal_2_1(self, mock_IbetStraightBondContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + db.commit() + + # Mock + bond_1 = IbetStraightBondContract() + token_attr = { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_1.__dict__ = token_attr + mock_IbetStraightBondContract_get.side_effect = [bond_1] + + # Request target API + resp = client.get(self.api_url) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 1, + "offset": None, + "limit": None, + "total": 1, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": "2025-01-31T21:34:56+09:00", + "token_status": 1, + "contract_version": "24_09", + "token_attributes": token_attr, + } + ], + } + + # + # TokenType = IbetShare + @pytest.mark.freeze_time("2025-01-31 12:34:56") + @mock.patch("app.model.blockchain.token.IbetShareContract.get") + def test_normal_2_2(self, mock_IbetShareContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_SHARE + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + db.commit() + + # Mock + share_1 = IbetShareContract() + token_attr = { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "name": "テスト株式-test", + "symbol": "TEST-test", + "total_supply": 999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": False, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "issue_price": 999997, + "cancellation_date": "99991231", + "memo": "memo_test", + "principal_value": 999998, + "is_canceled": True, + "dividends": 9.99, + "dividend_record_date": "99991230", + "dividend_payment_date": "99991229", + } + share_1.__dict__ = token_attr + mock_IbetShareContract_get.side_effect = [share_1] + + # Request target API + resp = client.get(self.api_url) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 1, + "offset": None, + "limit": None, + "total": 1, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "token_type": TokenType.IBET_SHARE, + "created": "2025-01-31T21:34:56+09:00", + "token_status": 1, + "contract_version": "24_09", + "token_attributes": token_attr, + } + ], + } + + # + # Multiple records + @mock.patch("app.model.blockchain.token.IbetStraightBondContract.get") + @mock.patch("app.model.blockchain.token.IbetShareContract.get") + def test_normal_3( + self, mock_IbetShareContract_get, mock_IbetStraightBondContract_get, client, db + ): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_2 + _token.issuer_address = self.issuer_address_2 + _token.type = TokenType.IBET_SHARE + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + db.commit() + + # Mock + bond_1 = IbetStraightBondContract() + bond_token_attr = { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_1.__dict__ = bond_token_attr + mock_IbetStraightBondContract_get.side_effect = [bond_1] + + share_1 = IbetShareContract() + share_token_attr = { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "name": "テスト株式-test", + "symbol": "TEST-test", + "total_supply": 999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": False, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "issue_price": 999997, + "cancellation_date": "99991231", + "memo": "memo_test", + "principal_value": 999998, + "is_canceled": True, + "dividends": 9.99, + "dividend_record_date": "99991230", + "dividend_payment_date": "99991229", + } + share_1.__dict__ = share_token_attr + mock_IbetShareContract_get.side_effect = [share_1] + + # Request target API + resp = client.get(self.api_url) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 2, + "offset": None, + "limit": None, + "total": 2, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "token_type": TokenType.IBET_SHARE, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": share_token_attr, + }, + { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_token_attr, + }, + ], + } + + # + # Base query filtering: issuer address + @mock.patch("app.model.blockchain.token.IbetStraightBondContract.get") + def test_normal_4_1(self, mock_IbetStraightBondContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_2 + _token.issuer_address = self.issuer_address_2 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + db.commit() + + # Mock + bond_1 = IbetStraightBondContract() + bond_1_attr = { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_1.__dict__ = bond_1_attr + mock_IbetStraightBondContract_get.side_effect = [bond_1] + + # Request target API + resp = client.get( + self.api_url, + headers={"issuer-address": self.issuer_address_1}, + ) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 1, + "offset": None, + "limit": None, + "total": 1, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_1_attr, + } + ], + } + + # + # Base query filtering: token_address_list + @mock.patch("app.model.blockchain.token.IbetStraightBondContract.get") + def test_normal_4_2(self, mock_IbetStraightBondContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_2 + _token.issuer_address = self.issuer_address_2 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + db.commit() + + # Mock + bond_2 = IbetStraightBondContract() + bond_2_attr = { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_2.__dict__ = bond_2_attr + mock_IbetStraightBondContract_get.side_effect = [bond_2] + + # Request target API + resp = client.get( + self.api_url, params={"token_address_list": [self.token_address_2]} + ) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 1, + "offset": None, + "limit": None, + "total": 1, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_2_attr, + } + ], + } + + # + # Search filtering: token_type + @mock.patch("app.model.blockchain.token.IbetShareContract.get") + def test_normal_5(self, mock_IbetShareContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_2 + _token.issuer_address = self.issuer_address_2 + _token.type = TokenType.IBET_SHARE + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + db.commit() + + # Mock + share_1 = IbetShareContract() + share_token_attr = { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "name": "テスト株式-test", + "symbol": "TEST-test", + "total_supply": 999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": False, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "issue_price": 999997, + "cancellation_date": "99991231", + "memo": "memo_test", + "principal_value": 999998, + "is_canceled": True, + "dividends": 9.99, + "dividend_record_date": "99991230", + "dividend_payment_date": "99991229", + } + share_1.__dict__ = share_token_attr + mock_IbetShareContract_get.side_effect = [share_1] + + # Request target API + resp = client.get(self.api_url, params={"token_type": "IbetShare"}) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 1, + "offset": None, + "limit": None, + "total": 2, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "token_type": TokenType.IBET_SHARE, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": share_token_attr, + }, + ], + } + + # + # Sort: created + @mock.patch("app.model.blockchain.token.IbetStraightBondContract.get") + def test_normal_6_1(self, mock_IbetStraightBondContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_2 + _token.issuer_address = self.issuer_address_2 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + db.commit() + + # Mock + bond_1 = IbetStraightBondContract() + bond_1_attr = { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_1.__dict__ = bond_1_attr + + bond_2 = IbetStraightBondContract() + bond_2_attr = { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_2.__dict__ = bond_2_attr + + mock_IbetStraightBondContract_get.side_effect = [bond_1, bond_2] + + # Request target API + resp = client.get( + self.api_url, params={"sort_item": "created", "sort_order": 0} + ) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 2, + "offset": None, + "limit": None, + "total": 2, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_1_attr, + }, + { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_2_attr, + }, + ], + } + + # + # Sort: token_address + @mock.patch("app.model.blockchain.token.IbetStraightBondContract.get") + def test_normal_6_2(self, mock_IbetStraightBondContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_2 + _token.issuer_address = self.issuer_address_2 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + db.commit() + + # Mock + bond_1 = IbetStraightBondContract() + bond_1_attr = { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_1.__dict__ = bond_1_attr + + bond_2 = IbetStraightBondContract() + bond_2_attr = { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_2.__dict__ = bond_2_attr + + mock_IbetStraightBondContract_get.side_effect = [bond_1, bond_2] + + # Request target API + resp = client.get( + self.api_url, params={"sort_item": "token_address", "sort_order": 0} + ) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 2, + "offset": None, + "limit": None, + "total": 2, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_1, + "token_address": self.token_address_1, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_1_attr, + }, + { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_2_attr, + }, + ], + } + + # + # Offset/Limit + @mock.patch("app.model.blockchain.token.IbetStraightBondContract.get") + def test_normal_7(self, mock_IbetStraightBondContract_get, client, db): + # Prepare data: Token + _token = Token() + _token.token_address = self.token_address_1 + _token.issuer_address = self.issuer_address_1 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_2 + _token.issuer_address = self.issuer_address_2 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + _token = Token() + _token.token_address = self.token_address_3 + _token.issuer_address = self.issuer_address_3 + _token.type = TokenType.IBET_STRAIGHT_BOND + _token.tx_hash = "" + _token.abi = {} + _token.version = TokenVersion.V_24_09 + db.add(_token) + + db.commit() + + # Mock + bond_2 = IbetStraightBondContract() + bond_2_attr = { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "name": "テスト債券-test", + "symbol": "TEST-test", + "total_supply": 9999999, + "contact_information": "test1", + "privacy_policy": "test2", + "tradable_exchange_contract_address": "0x1234567890123456789012345678901234567890", + "status": False, + "personal_info_contract_address": "0x1234567890123456789012345678901234567891", + "require_personal_info_registered": True, + "transferable": True, + "is_offering": True, + "transfer_approval_required": True, + "face_value": 9999998, + "face_value_currency": "JPY", + "interest_rate": 99.999, + "interest_payment_date": [ + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + "99991231", + ], + "interest_payment_currency": "JPY", + "redemption_date": "99991231", + "redemption_value": 9999997, + "redemption_value_currency": "JPY", + "return_date": "99991230", + "return_amount": "return_amount-test", + "base_fx_rate": 123.456789, + "purpose": "purpose-test", + "memo": "memo-test", + "is_redeemed": True, + } + bond_2.__dict__ = bond_2_attr + + mock_IbetStraightBondContract_get.side_effect = [bond_2] + + # Request target API + resp = client.get(self.api_url, params={"offset": 1, "limit": 1}) + + # Assertion + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 3, + "offset": 1, + "limit": 1, + "total": 3, + }, + "tokens": [ + { + "issuer_address": self.issuer_address_2, + "token_address": self.token_address_2, + "token_type": TokenType.IBET_STRAIGHT_BOND, + "created": mock.ANY, + "token_status": 1, + "contract_version": "24_09", + "token_attributes": bond_2_attr, + } + ], + } + + ########################################################################### + # Error + ########################################################################### + + # + # token_address_list: Invalid token address + # -> 422: RequestValidationError + def test_error_1_1(self, client, db): + # Request target API + resp = client.get( + self.api_url, params={"token_address_list": ["invalid_token_address"]} + ) + + # Assertion + assert resp.status_code == 422 + assert resp.json() == { + "meta": {"code": 1, "title": "RequestValidationError"}, + "detail": [ + { + "type": "value_error", + "loc": ["query", "token_address_list", 0], + "msg": "Value error, invalid ethereum address", + "input": "invalid_token_address", + "ctx": {"error": {}}, + } + ], + } + + # + # token_type: Invalid token address + # -> 422: RequestValidationError + def test_error_1_2(self, client, db): + # Request target API + resp = client.get(self.api_url, params={"token_type": "invalid_token_type"}) + + # Assertion + assert resp.status_code == 422 + assert resp.json() == { + "meta": {"code": 1, "title": "RequestValidationError"}, + "detail": [ + { + "type": "enum", + "loc": ["query", "token_type"], + "msg": "Input should be 'IbetStraightBond' or 'IbetShare'", + "input": "invalid_token_type", + "ctx": {"expected": "'IbetStraightBond' or 'IbetShare'"}, + } + ], + }