From bbdc696ff98930b439bfe898f20d47b691c22f43 Mon Sep 17 00:00:00 2001 From: hyeon Date: Thu, 18 Jan 2024 13:58:19 +0900 Subject: [PATCH 01/20] Add is_free column --- .../a2df38682595_add_is_free_column.py | 30 +++++++++++++++++++ common/models/product.py | 1 + 2 files changed, 31 insertions(+) create mode 100644 common/alembic/versions/a2df38682595_add_is_free_column.py diff --git a/common/alembic/versions/a2df38682595_add_is_free_column.py b/common/alembic/versions/a2df38682595_add_is_free_column.py new file mode 100644 index 00000000..0f2a7bad --- /dev/null +++ b/common/alembic/versions/a2df38682595_add_is_free_column.py @@ -0,0 +1,30 @@ +"""Add is_free column + +Revision ID: a2df38682595 +Revises: 2822c456abc3 +Create Date: 2024-01-18 13:55:23.731546 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a2df38682595' +down_revision = '2822c456abc3' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('product', sa.Column('is_free', sa.Boolean(), nullable=True, default=False)) + op.execute("UPDATE product SET is_free=FALSE") + op.alter_column("product", "is_free", nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('product', 'is_free') + # ### end Alembic commands ### diff --git a/common/models/product.py b/common/models/product.py index 057bbd1e..9a4def16 100644 --- a/common/models/product.py +++ b/common/models/product.py @@ -60,6 +60,7 @@ class Product(AutoIdMixin, TimeStampMixin, Base): google_sku = Column(Text, doc="SKU ID of google play store") apple_sku = Column(Text, doc="SKU ID of apple appstore") # product_type = Column(ENUM(ProductType), default=ProductType.SINGLE, nullable=False) + is_free = Column(Boolean, nullable=False, default=False, doc="Flag to set this product as free") daily_limit = Column(Integer, nullable=True, doc="Purchase limit in 24 hours") weekly_limit = Column(Integer, nullable=True, doc="Purchase limit in 7 days (24 * 7 hours)") account_limit = Column(Integer, nullable=True, doc="Purchase limit for each account (in lifetime)") From 46ce781038548663ce9f757e706242da74252ec3 Mon Sep 17 00:00:00 2001 From: hyeon Date: Thu, 18 Jan 2024 14:39:24 +0900 Subject: [PATCH 02/20] Add is_free field to product schema --- iap/schemas/product.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iap/schemas/product.py b/iap/schemas/product.py index f02c3ac0..d0c2b8e2 100644 --- a/iap/schemas/product.py +++ b/iap/schemas/product.py @@ -2,7 +2,7 @@ from pydantic import BaseModel as BaseSchema, model_validator -from common.enums import Currency, ProductRarity, ProductAssetUISize +from common.enums import ProductRarity, ProductAssetUISize class SimpleProductSchema(BaseSchema): @@ -10,6 +10,7 @@ class SimpleProductSchema(BaseSchema): order: int google_sku: str apple_sku: str + is_free: bool # product_type: ProductType daily_limit: Optional[int] = None weekly_limit: Optional[int] = None From 21e188d05294fa216d959a18e682e720ff2489d9 Mon Sep 17 00:00:00 2001 From: hyeon Date: Thu, 18 Jan 2024 16:31:15 +0900 Subject: [PATCH 03/20] Add /purchase/free API for free product --- iap/api/purchase.py | 95 ++++++++++++++++++++++++++++++++++++++++-- iap/schemas/receipt.py | 20 +++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 635085ac..0408f47f 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -3,7 +3,7 @@ import os from datetime import datetime from typing import List, Dict, Optional, Annotated -from uuid import UUID +from uuid import UUID, uuid4 import boto3 import requests @@ -23,10 +23,10 @@ from iap.dependencies import session from iap.exceptions import ReceiptNotFoundException from iap.main import logger -from iap.schemas.receipt import ReceiptSchema, ReceiptDetailSchema +from iap.schemas.receipt import ReceiptSchema, ReceiptDetailSchema, FreeReceiptSchema from iap.utils import create_season_pass_jwt, get_purchase_count -from iap.validator.common import get_order_data from iap.validator.apple import validate_apple +from iap.validator.common import get_order_data from iap.validator.google import validate_google router = APIRouter( @@ -307,6 +307,95 @@ def request_product(receipt_data: ReceiptSchema, sess=Depends(session)): return receipt +@router.post("/free", response_model=ReceiptDetailSchema) +def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): + """ + # Purchase Free Product + --- + + **Purchase free product and unload product from IAP garage to buyer.** + + ### Request Body + - `store` :: int : Store type in IntEnum. Please see `StoreType` Enum. + - `agentAddress` :: str : 9c agent address of buyer. + - `avatarAddress` :: str : 9c avatar address to get items. + - `data` :: str : JSON serialized string of details of receipt. + - product_id :: int : Purchased product ID + """ + if not receipt_data.planetId: + raise ReceiptNotFoundException("", "") + + product_id = receipt_data.product_id + product = sess.scalar( + select(Product) + .options(joinedload(Product.fav_list)).options(joinedload(Product.fungible_item_list)) + .where(Product.active.is_(True), Product.id == product_id) + ) + receipt = Receipt( + store=receipt_data.store, + agent_addr=receipt_data.agentAddress.lower(), + avatar_addr=receipt_data.avatarAddress.lower(), + order_id=f"FREE-{uuid4()}", + purchased_at=datetime.utcnow(), + product_id=product.id if product is not None else None, + planet_id=receipt_data.planetId.value, + ) + sess.add(receipt) + sess.commit() + sess.refresh(receipt) + + # Validation + if not product: + receipt.status = ReceiptStatus.INVALID + receipt.msg = f"Product {receipt_data.product_id} not exists or inactive" + raise_error(sess, receipt, ValueError(f"Product {receipt_data.product_id} not found or inactive")) + + if not product.is_free: + receipt.status = ReceiptStatus.INVALID + receipt.msg = "This product it not for free" + raise_error(sess, receipt, ValueError(f"Requested product {product.id}::{product.name} is not for free")) + + if ((product.open_timestamp and product.open_timestamp > datetime.now()) or + (product.close_timestamp and product.close_timestamp < datetime.now())): + receipt.status = ReceiptStatus.TIME_LIMIT + raise_error(sess, receipt, ValueError(f"Not in product opening time")) + + # Purchase Limit + if (product.daily_limit and + get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), + agent_addr=receipt.agent_addr.lower(), daily_limit=True) > product.daily_limit): + receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED + raise_error(sess, receipt, ValueError("Daily purchase limit exceeded.")) + elif (product.weekly_limit and + get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), + agent_addr=receipt.agent_addr.lower(), weekly_limit=True) > product.weekly_limit): + receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED + raise_error(sess, receipt, ValueError("Weekly purchase limit exceeded.")) + elif (product.account_limit and + get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), + agent_addr=receipt.agent_addr.lower()) > product.account_limit): + receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED + raise_error(sess, receipt, ValueError("Account purchase limit exceeded.")) + + receipt.status = ReceiptStatus.VALID + sess.add(receipt) + sess.commit() + sess.refresh(receipt) + + msg = { + "agent_addr": receipt_data.agentAddress.lower(), + "avatar_addr": receipt_data.avatarAddress.lower(), + "product_id": product.id, + "uuid": str(receipt.uuid), + "planet_id": receipt_data.planetId.decode('utf-8'), + } + + resp = sqs.send_message(QueueUrl=SQS_URL, MessageBody=json.dumps(msg)) + logger.debug(f"message [{resp['MessageId']}] sent to SQS.") + + return receipt + + @router.get("/status", response_model=Dict[UUID, Optional[ReceiptDetailSchema]]) def purchase_status(uuid: Annotated[List[UUID], Query()] = ..., sess=Depends(session)): """ diff --git a/iap/schemas/receipt.py b/iap/schemas/receipt.py index 78b79ef5..488b10ef 100644 --- a/iap/schemas/receipt.py +++ b/iap/schemas/receipt.py @@ -58,6 +58,26 @@ def json_data(self) -> dict: return data +@dataclass +class FreeReceiptSchema: + product_id: int + agentAddress: str + avatarAddress: str + store: Store + planetId: Union[str, PlanetID] + + def __post_init__(self): + # Reformat address to starts with `0x` + if self.agentAddress: + self.agentAddress = format_addr(self.agentAddress) + if self.avatarAddress: + self.avatarAddress = format_addr(self.avatarAddress) + + # Parse planet + if isinstance(self.planetId, str): + self.planetId = PlanetID(bytes(self.planetId, 'utf-8')) + + @dataclass class ReceiptSchema: data: Union[str, Dict, object] From 689588de460cbb8fe6731c65706ee29a79bc0a53 Mon Sep 17 00:00:00 2001 From: hyeon Date: Thu, 18 Jan 2024 17:10:07 +0900 Subject: [PATCH 04/20] product_id is not in data for free product --- iap/api/purchase.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 0408f47f..df6563cd 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -319,8 +319,7 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): - `store` :: int : Store type in IntEnum. Please see `StoreType` Enum. - `agentAddress` :: str : 9c agent address of buyer. - `avatarAddress` :: str : 9c avatar address to get items. - - `data` :: str : JSON serialized string of details of receipt. - - product_id :: int : Purchased product ID + - product_id :: int : Purchased product ID """ if not receipt_data.planetId: raise ReceiptNotFoundException("", "") From 8f843019c709e36feba528a13e0bce6617d2af90 Mon Sep 17 00:00:00 2001 From: hyeon Date: Thu, 18 Jan 2024 17:11:59 +0900 Subject: [PATCH 05/20] Deploy by internal branch --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 74ede92d..6be9ccf5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - development + - internal - release/* - main pull_request: @@ -58,7 +59,7 @@ jobs: deploy_without_approval: # This is for development / internal deployment - if: ${{ github.ref == 'refs/heads/development' || startsWith(github.ref, 'refs/heads/release') }} + if: ${{ github.ref == 'refs/heads/development' || github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release') }} needs: [ "test", "build_frontend", "synth" ] uses: ./.github/workflows/deploy.yml with: From 7b182e6945f6d6b84266f159da87d763f52f659b Mon Sep 17 00:00:00 2001 From: hyeon Date: Thu, 18 Jan 2024 17:28:41 +0900 Subject: [PATCH 06/20] internal stage must be set explicitly --- .github/workflows/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6be9ccf5..6bd6d7a9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: test: uses: ./.github/workflows/test.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || (startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development') }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} secrets: APPLE_CREDENTIAL: ${{ secrets.APPLE_CREDENTIAL }} APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} @@ -29,14 +29,14 @@ jobs: build_frontend: uses: ./.github/workflows/build_frontend.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || (startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development') }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} secrets: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} synth: uses: ./.github/workflows/synth.yml with: - environment: ${{ startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development' }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} secrets: ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -63,7 +63,7 @@ jobs: needs: [ "test", "build_frontend", "synth" ] uses: ./.github/workflows/deploy.yml with: - environment: ${{ startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development' }} + environment: ${{ (github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development' }} secrets: ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} From 1463bda18174b6e23d01bafff8dd80784ffc1800 Mon Sep 17 00:00:00 2001 From: hyeon Date: Thu, 18 Jan 2024 18:04:17 +0900 Subject: [PATCH 07/20] Update schema - Add product ID - Add default values for SKU --- iap/api/purchase.py | 2 +- iap/schemas/product.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index df6563cd..3af58b52 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -319,7 +319,7 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): - `store` :: int : Store type in IntEnum. Please see `StoreType` Enum. - `agentAddress` :: str : 9c agent address of buyer. - `avatarAddress` :: str : 9c avatar address to get items. - - product_id :: int : Purchased product ID + - `product_id` :: int : Purchased product ID """ if not receipt_data.planetId: raise ReceiptNotFoundException("", "") diff --git a/iap/schemas/product.py b/iap/schemas/product.py index d0c2b8e2..02f271b7 100644 --- a/iap/schemas/product.py +++ b/iap/schemas/product.py @@ -6,10 +6,11 @@ class SimpleProductSchema(BaseSchema): + id: int name: str order: int - google_sku: str - apple_sku: str + google_sku: str = "" + apple_sku: str = "" is_free: bool # product_type: ProductType daily_limit: Optional[int] = None From 9837cdb714c11cd7c57ef3683d365f4a4d5f557b Mon Sep 17 00:00:00 2001 From: hyeon Date: Fri, 19 Jan 2024 12:31:59 +0900 Subject: [PATCH 08/20] Update garage slack message - Add stage into title to check network - Sort garage data in certain order --- worker/worker/status_monitor.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/worker/worker/status_monitor.py b/worker/worker/status_monitor.py index f1b419d6..fde4f504 100644 --- a/worker/worker/status_monitor.py +++ b/worker/worker/status_monitor.py @@ -12,6 +12,7 @@ from common.utils.aws import fetch_secrets from common.utils.receipt import PlanetID +STAGE = os.environ.get("STAGE") DB_URI = os.environ.get("DB_URI") db_password = fetch_secrets(os.environ.get("REGION_NAME"), os.environ.get("SECRET_ARN"))["password"] DB_URI = DB_URI.replace("[DB_PASSWORD]", db_password) @@ -28,6 +29,17 @@ "48e50ecd6d1aa2689fd349c1f0611e6cc1e9c4c74ec4de9d4637ec7b78617308": "Golden Meat (800202)", } +VIEW_ORDER = ( + "CRYSTAL", + FUNGIBLE_DICT["3991e04dd808dc0bc24b21f5adb7bf1997312f8700daf1334bf34936e8a0813a"], # Hourglass + FUNGIBLE_DICT["00dfffe23964af9b284d121dae476571b7836b8d9e2e5f510d92a840fecc64fe"], # AP Potion + "RUNE_GOLDENLEAF", + FUNGIBLE_DICT["f8faf92c9c0d0e8e06694361ea87bfc8b29a8ae8de93044b98470a57636ed0e0"], # Golden Dust + FUNGIBLE_DICT["48e50ecd6d1aa2689fd349c1f0611e6cc1e9c4c74ec4de9d4637ec7b78617308"], # Golden Meat + FUNGIBLE_DICT["1a755098a2bc0659a063107df62e2ff9b3cdaba34d96b79519f504b996f53820"], # Silver Dust + "SOULSTONE_1001", "SOULSTONE_1002", "SOULSTONE_1003", "SOULSTONE_1004", +) + engine = create_engine(DB_URI) @@ -148,13 +160,21 @@ def check_garage(): fav_data = data["garageBalances"] item_data = data["fungibleItemGarages"] - msg = [] + result_dict = {} + for fav in fav_data: - msg.append(create_block(f"{fav['currency']['ticker']} : {int(fav['quantity'].split('.')[0]):,}")) + result_dict[fav["currency"]["ticker"]] = fav["quantity"].split(".")[0] for item in item_data: - msg.append(create_block(f"{FUNGIBLE_DICT[item['fungibleItemId']]} : {item['count']:,}")) + result_dict[FUNGIBLE_DICT[item["fungibleItemId"]]] = item["count"] + + msg = [] + for key in VIEW_ORDER: + result = result_dict.pop(key) + msg.append(create_block(f"{key} : {int(result):,}")) + for key, result in result_dict.items(): + msg.append(create_block(f"{key} : {int(result):,}")) - send_message(IAP_GARAGE_WEBHOOK_URL, "[NineChronicles.IAP] Daily Garage Report", msg) + send_message(IAP_GARAGE_WEBHOOK_URL, f"[NineChronicles.IAP] Daily Garage Report - {STAGE}", msg) def handle(event, context): From b79b75c5b45d962a61721644c0de4079999bea11 Mon Sep 17 00:00:00 2001 From: hyeon Date: Mon, 22 Jan 2024 12:40:38 +0900 Subject: [PATCH 09/20] Add required level - Add required_level column - Add REQUIRED_LEVEL receipt status --- .../90ff6ac09fe5_add_required_level_column.py | 59 +++++++++++++++++++ common/enums.py | 5 ++ common/models/product.py | 1 + 3 files changed, 65 insertions(+) create mode 100644 common/alembic/versions/90ff6ac09fe5_add_required_level_column.py diff --git a/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py b/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py new file mode 100644 index 00000000..9f2777f1 --- /dev/null +++ b/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py @@ -0,0 +1,59 @@ +"""Add required_level column + +Revision ID: 90ff6ac09fe5 +Revises: a2df38682595 +Create Date: 2024-01-22 12:31:38.823682 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '90ff6ac09fe5' +down_revision = 'a2df38682595' +branch_labels = None +depends_on = None + +old_enum = ("INIT", "VALIDATION_REQUEST", "VALID", "REFUNDED_BY_ADMIN", "INVALID", "REFUNDED_BY_BUYER", + "PURCHASE_LIMIT_EXCEED", "TIME_LIMIT", "UNKNOWN") +new_enum = sorted(old_enum + ("REQUIRED_LEVEL",)) + +old_status = sa.Enum(*old_enum, name="receiptstatus") +new_status = sa.Enum(*new_enum, name="receiptstatus") +tmp_status = sa.Enum(*new_enum, name="_receiptstatus") + +receipt_table = sa.sql.table( + "receipt", + sa.Column("status", new_status, nullable=False) +) + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + tmp_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE _receiptstatus USING status::text::_receiptstatus") + old_status.drop(op.get_bind(), checkfirst=False) + new_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE receiptstatus USING status::text::receiptstatus") + tmp_status.drop(op.get_bind(), checkfirst=False) + + op.add_column('product', sa.Column('required_level', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('product', 'required_level') + + op.execute(receipt_table.update() + .where(receipt_table.c.status.in_(['REQUIRED_LEVEL'])) + .values(status="INVALID") + ) + tmp_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE _receiptstatus USING status::text::_receiptstatus") + new_status.drop(op.get_bind(), checkfirst=False) + old_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE receiptstatus USING status::text::receiptstatus") + tmp_status.drop(op.get_bind(), checkfirst=False) + # ### end Alembic commands ### diff --git a/common/enums.py b/common/enums.py index 744df567..96e15cae 100644 --- a/common/enums.py +++ b/common/enums.py @@ -131,6 +131,10 @@ class ReceiptStatus(IntEnum): Purchase timestamp is not between target product's open/close timestamp. This purchase is executed in appstore, so admin should refund this purchase in manual. + - **95: `REQUIRED_LEVEL`** + + Does not met required level to get this product. + - **99: `UNKNOWN`** An unhandled error case. This is reserve to catch all other errors. @@ -145,6 +149,7 @@ class ReceiptStatus(IntEnum): REFUNDED_BY_BUYER = 92 PURCHASE_LIMIT_EXCEED = 93 TIME_LIMIT = 94 + REQUIRED_LEVEL = 95 UNKNOWN = 99 diff --git a/common/models/product.py b/common/models/product.py index 9a4def16..c317ff59 100644 --- a/common/models/product.py +++ b/common/models/product.py @@ -61,6 +61,7 @@ class Product(AutoIdMixin, TimeStampMixin, Base): apple_sku = Column(Text, doc="SKU ID of apple appstore") # product_type = Column(ENUM(ProductType), default=ProductType.SINGLE, nullable=False) is_free = Column(Boolean, nullable=False, default=False, doc="Flag to set this product as free") + required_level = Column(Integer, nullable=True, default=None, doc="Required avatar level to purchase this product") daily_limit = Column(Integer, nullable=True, doc="Purchase limit in 24 hours") weekly_limit = Column(Integer, nullable=True, doc="Purchase limit in 7 days (24 * 7 hours)") account_limit = Column(Integer, nullable=True, doc="Purchase limit for each account (in lifetime)") From 422fad4ccfeabb926507656c2a4b1892be9f0ed7 Mon Sep 17 00:00:00 2001 From: hyeon Date: Mon, 22 Jan 2024 12:41:47 +0900 Subject: [PATCH 10/20] Update product schema --- iap/schemas/product.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iap/schemas/product.py b/iap/schemas/product.py index 02f271b7..c2202218 100644 --- a/iap/schemas/product.py +++ b/iap/schemas/product.py @@ -6,7 +6,6 @@ class SimpleProductSchema(BaseSchema): - id: int name: str order: int google_sku: str = "" @@ -18,6 +17,7 @@ class SimpleProductSchema(BaseSchema): account_limit: Optional[int] = None active: bool buyable: bool = True + required_level: Optional[int] = None class Config: from_attributes = True From de37b477530f093634fe4d60b535f0c23708ee66 Mon Sep 17 00:00:00 2001 From: hyeon Date: Mon, 22 Jan 2024 14:25:34 +0900 Subject: [PATCH 11/20] Add required level check logic --- iap/api/purchase.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 3af58b52..80d65f87 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -376,6 +376,20 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED raise_error(sess, receipt, ValueError("Account purchase limit exceeded.")) + # Required level + if product.required_level: + query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt_data.avatarAddress}") {{ level}} }} }}""" + resp = requests.post(os.environ.get("HEADLESS"), json={"query": query}) + try: + avatar_level = resp.json()["data"]["stateQuery"]["avatar"]["level"] + except: + avatar_level = 0 + + if avatar_level < product.required_level: + receipt.status = ReceiptStatus.REQUIRED_LEVEL + raise_error(sess, receipt, + ValueError(f"Avatar level {avatar_level} does not met required level {product.required_level}")) + receipt.status = ReceiptStatus.VALID sess.add(receipt) sess.commit() From afd15bf7e8e72760d178d1af2870f47764186564 Mon Sep 17 00:00:00 2001 From: hyeon Date: Mon, 22 Jan 2024 14:25:53 +0900 Subject: [PATCH 12/20] Add data field to fill required column --- iap/api/purchase.py | 1 + 1 file changed, 1 insertion(+) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 80d65f87..516767e5 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -332,6 +332,7 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): ) receipt = Receipt( store=receipt_data.store, + data="", agent_addr=receipt_data.agentAddress.lower(), avatar_addr=receipt_data.avatarAddress.lower(), order_id=f"FREE-{uuid4()}", From da0b89946afc892166b18e1e9afcce80fbbb11ab Mon Sep 17 00:00:00 2001 From: hyeon Date: Mon, 22 Jan 2024 16:58:35 +0900 Subject: [PATCH 13/20] Use SKU instead of product_id and save it --- iap/api/purchase.py | 15 +++++++++------ iap/schemas/receipt.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 516767e5..1b6cb1b6 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -9,7 +9,7 @@ import requests from fastapi import APIRouter, Depends, Query from googleapiclient.errors import HttpError -from sqlalchemy import select +from sqlalchemy import select, or_ from sqlalchemy.orm import joinedload from starlette.responses import JSONResponse @@ -319,23 +319,26 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): - `store` :: int : Store type in IntEnum. Please see `StoreType` Enum. - `agentAddress` :: str : 9c agent address of buyer. - `avatarAddress` :: str : 9c avatar address to get items. - - `product_id` :: int : Purchased product ID + - `sku` :: str : Purchased product SKU """ if not receipt_data.planetId: raise ReceiptNotFoundException("", "") - product_id = receipt_data.product_id product = sess.scalar( select(Product) .options(joinedload(Product.fav_list)).options(joinedload(Product.fungible_item_list)) - .where(Product.active.is_(True), Product.id == product_id) + .where( + Product.active.is_(True), + or_(Product.google_sku == receipt_data.sku, Product.apple_sku == receipt_data.sku) + ) ) + order_id = f"FREE-{uuid4()}" receipt = Receipt( store=receipt_data.store, - data="", + data={"SKU": receipt_data.sku, "OrderId": order_id}, agent_addr=receipt_data.agentAddress.lower(), avatar_addr=receipt_data.avatarAddress.lower(), - order_id=f"FREE-{uuid4()}", + order_id=order_id, purchased_at=datetime.utcnow(), product_id=product.id if product is not None else None, planet_id=receipt_data.planetId.value, diff --git a/iap/schemas/receipt.py b/iap/schemas/receipt.py index 488b10ef..68353406 100644 --- a/iap/schemas/receipt.py +++ b/iap/schemas/receipt.py @@ -60,7 +60,7 @@ def json_data(self) -> dict: @dataclass class FreeReceiptSchema: - product_id: int + sku: str agentAddress: str avatarAddress: str store: Store From 44e0957ba3edf03909f6e2b1f54588c63497ee5e Mon Sep 17 00:00:00 2001 From: hyeon Date: Mon, 22 Jan 2024 17:02:09 +0900 Subject: [PATCH 14/20] Add timeout to avoid too long waiting --- iap/api/purchase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 1b6cb1b6..90545d63 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -383,7 +383,7 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): # Required level if product.required_level: query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt_data.avatarAddress}") {{ level}} }} }}""" - resp = requests.post(os.environ.get("HEADLESS"), json={"query": query}) + resp = requests.post(os.environ.get("HEADLESS"), json={"query": query}, timeout=1) try: avatar_level = resp.json()["data"]["stateQuery"]["avatar"]["level"] except: From 29c8bb64d448c2b46c05e894d0c1444e2f01f5ca Mon Sep 17 00:00:00 2001 From: hyeon Date: Mon, 22 Jan 2024 17:23:06 +0900 Subject: [PATCH 15/20] Check response itself inside try-except --- iap/api/purchase.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 90545d63..ee360959 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -383,10 +383,11 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): # Required level if product.required_level: query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt_data.avatarAddress}") {{ level}} }} }}""" - resp = requests.post(os.environ.get("HEADLESS"), json={"query": query}, timeout=1) try: + resp = requests.post(os.environ.get("HEADLESS"), json={"query": query}, timeout=1) avatar_level = resp.json()["data"]["stateQuery"]["avatar"]["level"] except: + # Whether request is failed or no fitted data found avatar_level = 0 if avatar_level < product.required_level: From 52ec04864288df22748de0e862c4670cd3357abb Mon Sep 17 00:00:00 2001 From: hyeon Date: Tue, 23 Jan 2024 12:07:20 +0900 Subject: [PATCH 16/20] Find avatar in right planet --- .github/workflows/deploy.yml | 4 ++++ .github/workflows/synth.yml | 2 ++ common/__init__.py | 2 ++ iap/api/purchase.py | 8 +++++++- iap/iap_cdk_stack.py | 2 ++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d47c9434..651d4a6a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -130,6 +130,8 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} + ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} + HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} @@ -163,6 +165,8 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} + ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} + HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} diff --git a/.github/workflows/synth.yml b/.github/workflows/synth.yml index 2a2d4a4e..0d5a1358 100644 --- a/.github/workflows/synth.yml +++ b/.github/workflows/synth.yml @@ -117,6 +117,8 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} + ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} + HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} diff --git a/common/__init__.py b/common/__init__.py index 33b7f0d4..9a45b7a5 100644 --- a/common/__init__.py +++ b/common/__init__.py @@ -36,6 +36,8 @@ class Config: account_id: str region_name: str cdn_host: str + odin_gql_url: str + heimdall_gql_url: str # Multiplanetary planet_url: str diff --git a/iap/api/purchase.py b/iap/api/purchase.py index ee360959..f60130da 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -382,9 +382,15 @@ def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): # Required level if product.required_level: + gql_url = None + if receipt_data.planetId in (PlanetID.ODIN, PlanetID.ODIN_INTERNAL): + gql_url = os.environ.get("ODIN_GQL_URL") + elif receipt_data.planetId in (PlanetID.HEIMDALL, PlanetID.HEIMDALL_INTERNAL): + gql_url = os.environ.get("HEIMDALL_GQL_URL") + query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt_data.avatarAddress}") {{ level}} }} }}""" try: - resp = requests.post(os.environ.get("HEADLESS"), json={"query": query}, timeout=1) + resp = requests.post(gql_url, json={"query": query}, timeout=1) avatar_level = resp.json()["data"]["stateQuery"]["avatar"]["level"] except: # Whether request is failed or no fitted data found diff --git a/iap/iap_cdk_stack.py b/iap/iap_cdk_stack.py index cab8814a..6acb2b2c 100644 --- a/iap/iap_cdk_stack.py +++ b/iap/iap_cdk_stack.py @@ -98,6 +98,8 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: "APPLE_KEY_ID": config.apple_key_id, "APPLE_ISSUER_ID": config.apple_issuer_id, "HEADLESS": config.headless, + "ODIN_GQL_URL": config.odin_gql_url, + "HEIMDALL_GQL_URL": config.heimdall_gql_url, "CDN_HOST": config.cdn_host, "PLANET_URL": config.planet_url, "BRIDGE_DATA": config.bridge_data, From 64fca764cfc305e299ca6ceb5c07a997f2f3442c Mon Sep 17 00:00:00 2001 From: hyeon Date: Tue, 23 Jan 2024 14:11:15 +0900 Subject: [PATCH 17/20] Fix: Remove conflicting old code --- worker/worker/handler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/worker/worker/handler.py b/worker/worker/handler.py index 50f4dce9..e5c753c0 100644 --- a/worker/worker/handler.py +++ b/worker/worker/handler.py @@ -117,7 +117,7 @@ def process(sess: Session, message: SQSMessageRecord, nonce: int = None) -> Tupl } for x in product.fungible_item_list] unload_from_garage = create_unload_my_garages_action_plain_value( - id=uuid.uuid1().hex, + _id=uuid.uuid1().hex, fav_data=fav_data, avatar_addr=avatar_address, item_data=item_data, @@ -130,9 +130,6 @@ def process(sess: Session, message: SQSMessageRecord, nonce: int = None) -> Tupl plain_value=unload_from_garage, timestamp=datetime.datetime.utcnow() + datetime.timedelta(days=1) ) - unsigned_tx = gql.create_action("claim_items", pubkey=account.pubkey.hex(), nonce=nonce, - claim_data={}, memo=memo) - signature = account.sign_tx(unsigned_tx) signed_tx = append_signature_to_unsigned_tx(unsigned_tx, signature) return gql.stage(signed_tx), nonce, signed_tx From 6a850360ce7021bb5447fd14dd812831e3f07200 Mon Sep 17 00:00:00 2001 From: "ulismoon (hyeon)" Date: Fri, 26 Jan 2024 11:27:00 +0900 Subject: [PATCH 18/20] Revert "0.10.0" --- .github/workflows/deploy.yml | 4 - .github/workflows/main.yml | 11 +- .github/workflows/synth.yml | 2 - common/__init__.py | 2 - common/_graphql.py | 22 ---- .../90ff6ac09fe5_add_required_level_column.py | 59 --------- .../a2df38682595_add_is_free_column.py | 30 ----- common/enums.py | 5 - common/lib9c/currency.py | 31 +++++ common/lib9c/fungible_asset.py | 16 +++ common/lib9c/models/address.py | 24 ---- common/lib9c/models/currency.py | 62 --------- common/lib9c/models/fungible_asset_value.py | 35 ----- common/models/product.py | 2 - common/utils/actions.py | 12 +- iap/api/purchase.py | 121 +----------------- iap/iap_cdk_stack.py | 2 - iap/schemas/product.py | 8 +- iap/schemas/receipt.py | 20 --- tests/lib9c/models/test_address.py | 32 ----- tests/lib9c/models/test_currency.py | 58 --------- .../lib9c/models/test_fungible_asset_value.py | 70 ---------- tests/lib9c/test_currency.py | 30 +++++ tests/lib9c/test_fungible_asset.py | 13 ++ tests/utils/test_actions.py | 4 +- worker/worker/handler.py | 3 +- worker/worker/status_monitor.py | 28 +--- 27 files changed, 113 insertions(+), 593 deletions(-) delete mode 100644 common/alembic/versions/90ff6ac09fe5_add_required_level_column.py delete mode 100644 common/alembic/versions/a2df38682595_add_is_free_column.py create mode 100644 common/lib9c/currency.py create mode 100644 common/lib9c/fungible_asset.py delete mode 100644 common/lib9c/models/address.py delete mode 100644 common/lib9c/models/currency.py delete mode 100644 common/lib9c/models/fungible_asset_value.py delete mode 100644 tests/lib9c/models/test_address.py delete mode 100644 tests/lib9c/models/test_currency.py delete mode 100644 tests/lib9c/models/test_fungible_asset_value.py create mode 100644 tests/lib9c/test_currency.py create mode 100644 tests/lib9c/test_fungible_asset.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 651d4a6a..d47c9434 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -130,8 +130,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} - ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} - HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} @@ -165,8 +163,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} - ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} - HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6bd6d7a9..74ede92d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,7 +4,6 @@ on: push: branches: - development - - internal - release/* - main pull_request: @@ -17,7 +16,7 @@ jobs: test: uses: ./.github/workflows/test.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || (startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development') }} secrets: APPLE_CREDENTIAL: ${{ secrets.APPLE_CREDENTIAL }} APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} @@ -29,14 +28,14 @@ jobs: build_frontend: uses: ./.github/workflows/build_frontend.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || (startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development') }} secrets: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} synth: uses: ./.github/workflows/synth.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} + environment: ${{ startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development' }} secrets: ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -59,11 +58,11 @@ jobs: deploy_without_approval: # This is for development / internal deployment - if: ${{ github.ref == 'refs/heads/development' || github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release') }} + if: ${{ github.ref == 'refs/heads/development' || startsWith(github.ref, 'refs/heads/release') }} needs: [ "test", "build_frontend", "synth" ] uses: ./.github/workflows/deploy.yml with: - environment: ${{ (github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development' }} + environment: ${{ startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development' }} secrets: ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/.github/workflows/synth.yml b/.github/workflows/synth.yml index 0d5a1358..2a2d4a4e 100644 --- a/.github/workflows/synth.yml +++ b/.github/workflows/synth.yml @@ -117,8 +117,6 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} - ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} - HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} diff --git a/common/__init__.py b/common/__init__.py index 9a45b7a5..33b7f0d4 100644 --- a/common/__init__.py +++ b/common/__init__.py @@ -36,8 +36,6 @@ class Config: account_id: str region_name: str cdn_host: str - odin_gql_url: str - heimdall_gql_url: str # Multiplanetary planet_url: str diff --git a/common/_graphql.py b/common/_graphql.py index af6ddb78..1ed228a2 100644 --- a/common/_graphql.py +++ b/common/_graphql.py @@ -80,28 +80,6 @@ def _unload_from_garage(self, pubkey: bytes, nonce: int, **kwargs) -> bytes: result = self.execute(query) return bytes.fromhex(result["actionTxQuery"]["unloadFromMyGarages"]) - def _claim_items(self, pubkey: bytes, nonce: int, **kwargs) -> bytes: - ts = kwargs.get("timestamp", (datetime.datetime.utcnow()+datetime.timedelta(days=1)).isoformat()) - claim_data = kwargs.get("claim_data") - memo = kwargs.get("memo") - - query = dsl_gql( - DSLQuery( - self.ds.StandaloneQuery.actionTxQuery.args( - publicKey=pubkey.hex(), - nonce=nonce, - timestamp=ts, - ).select( - self.ds.ActionTxQuery.claimItems.args( - claimData=claim_data, - memo=memo, - ) - ) - ) - ) - result = self.execute(query) - return bytes.fromhex(result["actionTxQuery"]["unloadFromMyGarages"]) - def _transfer_asset(self, pubkey: bytes, nonce: int, **kwargs) -> bytes: ts = kwargs.get("timestamp", (datetime.datetime.utcnow()+datetime.timedelta(days=1)).isoformat()) sender = kwargs.get("sender") diff --git a/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py b/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py deleted file mode 100644 index 9f2777f1..00000000 --- a/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Add required_level column - -Revision ID: 90ff6ac09fe5 -Revises: a2df38682595 -Create Date: 2024-01-22 12:31:38.823682 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = '90ff6ac09fe5' -down_revision = 'a2df38682595' -branch_labels = None -depends_on = None - -old_enum = ("INIT", "VALIDATION_REQUEST", "VALID", "REFUNDED_BY_ADMIN", "INVALID", "REFUNDED_BY_BUYER", - "PURCHASE_LIMIT_EXCEED", "TIME_LIMIT", "UNKNOWN") -new_enum = sorted(old_enum + ("REQUIRED_LEVEL",)) - -old_status = sa.Enum(*old_enum, name="receiptstatus") -new_status = sa.Enum(*new_enum, name="receiptstatus") -tmp_status = sa.Enum(*new_enum, name="_receiptstatus") - -receipt_table = sa.sql.table( - "receipt", - sa.Column("status", new_status, nullable=False) -) - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - tmp_status.create(op.get_bind(), checkfirst=False) - op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE _receiptstatus USING status::text::_receiptstatus") - old_status.drop(op.get_bind(), checkfirst=False) - new_status.create(op.get_bind(), checkfirst=False) - op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE receiptstatus USING status::text::receiptstatus") - tmp_status.drop(op.get_bind(), checkfirst=False) - - op.add_column('product', sa.Column('required_level', sa.Integer(), nullable=True)) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('product', 'required_level') - - op.execute(receipt_table.update() - .where(receipt_table.c.status.in_(['REQUIRED_LEVEL'])) - .values(status="INVALID") - ) - tmp_status.create(op.get_bind(), checkfirst=False) - op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE _receiptstatus USING status::text::_receiptstatus") - new_status.drop(op.get_bind(), checkfirst=False) - old_status.create(op.get_bind(), checkfirst=False) - op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE receiptstatus USING status::text::receiptstatus") - tmp_status.drop(op.get_bind(), checkfirst=False) - # ### end Alembic commands ### diff --git a/common/alembic/versions/a2df38682595_add_is_free_column.py b/common/alembic/versions/a2df38682595_add_is_free_column.py deleted file mode 100644 index 0f2a7bad..00000000 --- a/common/alembic/versions/a2df38682595_add_is_free_column.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Add is_free column - -Revision ID: a2df38682595 -Revises: 2822c456abc3 -Create Date: 2024-01-18 13:55:23.731546 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = 'a2df38682595' -down_revision = '2822c456abc3' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('product', sa.Column('is_free', sa.Boolean(), nullable=True, default=False)) - op.execute("UPDATE product SET is_free=FALSE") - op.alter_column("product", "is_free", nullable=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('product', 'is_free') - # ### end Alembic commands ### diff --git a/common/enums.py b/common/enums.py index 96e15cae..744df567 100644 --- a/common/enums.py +++ b/common/enums.py @@ -131,10 +131,6 @@ class ReceiptStatus(IntEnum): Purchase timestamp is not between target product's open/close timestamp. This purchase is executed in appstore, so admin should refund this purchase in manual. - - **95: `REQUIRED_LEVEL`** - - Does not met required level to get this product. - - **99: `UNKNOWN`** An unhandled error case. This is reserve to catch all other errors. @@ -149,7 +145,6 @@ class ReceiptStatus(IntEnum): REFUNDED_BY_BUYER = 92 PURCHASE_LIMIT_EXCEED = 93 TIME_LIMIT = 94 - REQUIRED_LEVEL = 95 UNKNOWN = 99 diff --git a/common/lib9c/currency.py b/common/lib9c/currency.py new file mode 100644 index 00000000..2409476a --- /dev/null +++ b/common/lib9c/currency.py @@ -0,0 +1,31 @@ +from typing import Dict, Any, Union + +import bencodex + +class Currency(): + + @staticmethod + def to_currency(ticker: str) -> Dict[str, Union[str, int, None]]: + if ticker.lower() == "crystal": + return { + "decimalPlaces": b'\x12', + "minters": None, + "ticker": "CRYSTAL", + } + elif ticker.lower() == "garage": + return { + "decimalPlaces": b'\x12', + "minters": None, + "ticker": "GARAGE", + "totalSupplyTrackable": True, + } + else: + return { + "decimalPlaces": b'\x00', + "minters": None, + "ticker": ticker.upper(), + } + + @staticmethod + def serialize(currency: Dict[str, Union[str, int, None]]) -> bytes: + return bencodex.dumps(currency) diff --git a/common/lib9c/fungible_asset.py b/common/lib9c/fungible_asset.py new file mode 100644 index 00000000..ab56c6f9 --- /dev/null +++ b/common/lib9c/fungible_asset.py @@ -0,0 +1,16 @@ +from typing import Dict, Union, List + +import bencodex + +from common.lib9c.currency import Currency + + +class FungibleAsset(): + + @staticmethod + def to_fungible_asset(ticker: str, amount: int, decimalPlaces: int) -> List[Union[Dict[str, Union[str, int, None]], int]]: + return [Currency.to_currency(ticker), amount * max(1, 10 ** decimalPlaces)] + + @staticmethod + def serialize(fungible_asset: List[Union[Dict[str, Union[str, int, None]], int]]) -> bytes: + return bencodex.dumps([Currency.serialize(fungible_asset[0]), fungible_asset[1]]) diff --git a/common/lib9c/models/address.py b/common/lib9c/models/address.py deleted file mode 100644 index c8086946..00000000 --- a/common/lib9c/models/address.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - - -class Address: - def __init__(self, addr: str): - if addr.startswith("0x"): - if len(addr) != 42: - raise ValueError("Address with 0x prefix must have exact 42 chars.") - self.raw = bytes.fromhex(addr[2:]) - else: - if len(addr) != 40: - raise ValueError("Address without 0x prefix must have exact 40 chars.") - self.raw = bytes.fromhex(addr) - - @property - def long_format(self): - return f"0x{self.raw.hex()}" - - @property - def short_format(self): - return self.raw.hex() - - def __eq__(self, other: Address): - return self.raw == other.raw diff --git a/common/lib9c/models/currency.py b/common/lib9c/models/currency.py deleted file mode 100644 index 2baacf4c..00000000 --- a/common/lib9c/models/currency.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from typing import Dict, List, Optional, Any - -import bencodex - -from common.lib9c.models.address import Address - - -class Currency: - """ - # Currency - --- - Lib9c Currency model which has ticker, minters, decimal_places, total_supply_trackable. - `minters` will be automatically sanitized to `None` if empty list provided. - """ - - def __init__(self, ticker: str, decimal_places: int, minters: Optional[List[str]] = None, - total_supply_trackable: bool = False): - self.ticker = ticker - self.minters = [Address(x) for x in minters] if minters else None - self.decimal_places = decimal_places - self.total_supply_trackable = total_supply_trackable - - def __eq__(self, other: Currency): - return ( - self.ticker == other.ticker and - self.minters == other.minters and - self.decimal_places == other.decimal_places and - self.total_supply_trackable == other.total_supply_trackable - ) - - @classmethod - def NCG(cls): - return cls( - ticker="NCG", - minters=["47d082a115c63e7b58b1532d20e631538eafadde"], - decimal_places=2 - ) - - @classmethod - def CRYSTAL(cls): - return cls( - ticker="CRYSTAL", - minters=None, - decimal_places=18 - ) - - @property - def plain_value(self) -> Dict[str, Any]: - value = { - "ticker": self.ticker, - "decimalPlaces": chr(self.decimal_places).encode(), - "minters": [x.short_format for x in self.minters] if self.minters else None - } - if self.total_supply_trackable: - value["totalSupplyTrackable"] = True - return value - - @property - def serialized_plain_value(self) -> bytes: - return bencodex.dumps(self.plain_value) diff --git a/common/lib9c/models/fungible_asset_value.py b/common/lib9c/models/fungible_asset_value.py deleted file mode 100644 index 6ded1705..00000000 --- a/common/lib9c/models/fungible_asset_value.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from typing import Dict, List, Optional, Any - -import bencodex - -from common.lib9c.models.currency import Currency - - -class FungibleAssetValue: - def __init__(self, currency: Currency, amount: float): - self.currency = currency - self.amount = amount - - def __eq__(self, other: FungibleAssetValue): - return self.currency == other.currency and self.amount == other.amount - - @classmethod - def from_raw_data( - cls, - ticker: str, decimal_places: int, minters: Optional[List[str]] = None, total_supply_trackable: bool = False, - amount: float = 0 - ): - return cls( - Currency(ticker, decimal_places, minters, total_supply_trackable), - amount=amount - ) - - @property - def plain_value(self) -> List[Dict[str, Any] | float]: - return [self.currency.plain_value, self.amount * max(1, 10 ** self.currency.decimal_places)] - - @property - def serialized_plain_value(self) -> bytes: - return bencodex.dumps(self.plain_value) diff --git a/common/models/product.py b/common/models/product.py index c317ff59..057bbd1e 100644 --- a/common/models/product.py +++ b/common/models/product.py @@ -60,8 +60,6 @@ class Product(AutoIdMixin, TimeStampMixin, Base): google_sku = Column(Text, doc="SKU ID of google play store") apple_sku = Column(Text, doc="SKU ID of apple appstore") # product_type = Column(ENUM(ProductType), default=ProductType.SINGLE, nullable=False) - is_free = Column(Boolean, nullable=False, default=False, doc="Flag to set this product as free") - required_level = Column(Integer, nullable=True, default=None, doc="Required avatar level to purchase this product") daily_limit = Column(Integer, nullable=True, doc="Purchase limit in 24 hours") weekly_limit = Column(Integer, nullable=True, doc="Purchase limit in 7 days (24 * 7 hours)") account_limit = Column(Integer, nullable=True, doc="Purchase limit for each account (in lifetime)") diff --git a/common/utils/actions.py b/common/utils/actions.py index e87b1768..27a8299d 100644 --- a/common/utils/actions.py +++ b/common/utils/actions.py @@ -1,25 +1,21 @@ from typing import List, Optional, Dict, Any -from common.lib9c.models.fungible_asset_value import FungibleAssetValue +from common.lib9c.fungible_asset import FungibleAsset -def create_unload_my_garages_action_plain_value(_id: str, fav_data: List, avatar_addr: str, item_data: List, - memo: Optional[str]) -> Dict[str, Any]: +def create_unload_my_garages_action_plain_value(id: str, fav_data: List, avatar_addr: str, item_data: List, memo: Optional[str]) -> Dict[str, Any]: if avatar_addr.startswith("0x"): avatar_addr = avatar_addr[2:] return { 'type_id': 'unload_from_my_garages', 'values': { - 'id': bytes.fromhex(_id), + 'id': bytes.fromhex(id), "l": [ bytes.fromhex(avatar_addr), [ [ bytes.fromhex(x['balanceAddr'][2:] if x["balanceAddr"].startswith("0x") else x["balanceAddr"]), - FungibleAssetValue.from_raw_data( - x["value"]["currencyTicker"], x["value"]["decimalPlaces"], x["value"].get("minters", None), - amount=x["value"]["value"] - ).plain_value + FungibleAsset.to_fungible_asset(x['value']['currencyTicker'], int(x['value']['value']), int(x['value']['decimalPlaces'])) ] for x in fav_data ], diff --git a/iap/api/purchase.py b/iap/api/purchase.py index f60130da..635085ac 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -3,13 +3,13 @@ import os from datetime import datetime from typing import List, Dict, Optional, Annotated -from uuid import UUID, uuid4 +from uuid import UUID import boto3 import requests from fastapi import APIRouter, Depends, Query from googleapiclient.errors import HttpError -from sqlalchemy import select, or_ +from sqlalchemy import select from sqlalchemy.orm import joinedload from starlette.responses import JSONResponse @@ -23,10 +23,10 @@ from iap.dependencies import session from iap.exceptions import ReceiptNotFoundException from iap.main import logger -from iap.schemas.receipt import ReceiptSchema, ReceiptDetailSchema, FreeReceiptSchema +from iap.schemas.receipt import ReceiptSchema, ReceiptDetailSchema from iap.utils import create_season_pass_jwt, get_purchase_count -from iap.validator.apple import validate_apple from iap.validator.common import get_order_data +from iap.validator.apple import validate_apple from iap.validator.google import validate_google router = APIRouter( @@ -307,119 +307,6 @@ def request_product(receipt_data: ReceiptSchema, sess=Depends(session)): return receipt -@router.post("/free", response_model=ReceiptDetailSchema) -def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): - """ - # Purchase Free Product - --- - - **Purchase free product and unload product from IAP garage to buyer.** - - ### Request Body - - `store` :: int : Store type in IntEnum. Please see `StoreType` Enum. - - `agentAddress` :: str : 9c agent address of buyer. - - `avatarAddress` :: str : 9c avatar address to get items. - - `sku` :: str : Purchased product SKU - """ - if not receipt_data.planetId: - raise ReceiptNotFoundException("", "") - - product = sess.scalar( - select(Product) - .options(joinedload(Product.fav_list)).options(joinedload(Product.fungible_item_list)) - .where( - Product.active.is_(True), - or_(Product.google_sku == receipt_data.sku, Product.apple_sku == receipt_data.sku) - ) - ) - order_id = f"FREE-{uuid4()}" - receipt = Receipt( - store=receipt_data.store, - data={"SKU": receipt_data.sku, "OrderId": order_id}, - agent_addr=receipt_data.agentAddress.lower(), - avatar_addr=receipt_data.avatarAddress.lower(), - order_id=order_id, - purchased_at=datetime.utcnow(), - product_id=product.id if product is not None else None, - planet_id=receipt_data.planetId.value, - ) - sess.add(receipt) - sess.commit() - sess.refresh(receipt) - - # Validation - if not product: - receipt.status = ReceiptStatus.INVALID - receipt.msg = f"Product {receipt_data.product_id} not exists or inactive" - raise_error(sess, receipt, ValueError(f"Product {receipt_data.product_id} not found or inactive")) - - if not product.is_free: - receipt.status = ReceiptStatus.INVALID - receipt.msg = "This product it not for free" - raise_error(sess, receipt, ValueError(f"Requested product {product.id}::{product.name} is not for free")) - - if ((product.open_timestamp and product.open_timestamp > datetime.now()) or - (product.close_timestamp and product.close_timestamp < datetime.now())): - receipt.status = ReceiptStatus.TIME_LIMIT - raise_error(sess, receipt, ValueError(f"Not in product opening time")) - - # Purchase Limit - if (product.daily_limit and - get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), - agent_addr=receipt.agent_addr.lower(), daily_limit=True) > product.daily_limit): - receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED - raise_error(sess, receipt, ValueError("Daily purchase limit exceeded.")) - elif (product.weekly_limit and - get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), - agent_addr=receipt.agent_addr.lower(), weekly_limit=True) > product.weekly_limit): - receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED - raise_error(sess, receipt, ValueError("Weekly purchase limit exceeded.")) - elif (product.account_limit and - get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), - agent_addr=receipt.agent_addr.lower()) > product.account_limit): - receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED - raise_error(sess, receipt, ValueError("Account purchase limit exceeded.")) - - # Required level - if product.required_level: - gql_url = None - if receipt_data.planetId in (PlanetID.ODIN, PlanetID.ODIN_INTERNAL): - gql_url = os.environ.get("ODIN_GQL_URL") - elif receipt_data.planetId in (PlanetID.HEIMDALL, PlanetID.HEIMDALL_INTERNAL): - gql_url = os.environ.get("HEIMDALL_GQL_URL") - - query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt_data.avatarAddress}") {{ level}} }} }}""" - try: - resp = requests.post(gql_url, json={"query": query}, timeout=1) - avatar_level = resp.json()["data"]["stateQuery"]["avatar"]["level"] - except: - # Whether request is failed or no fitted data found - avatar_level = 0 - - if avatar_level < product.required_level: - receipt.status = ReceiptStatus.REQUIRED_LEVEL - raise_error(sess, receipt, - ValueError(f"Avatar level {avatar_level} does not met required level {product.required_level}")) - - receipt.status = ReceiptStatus.VALID - sess.add(receipt) - sess.commit() - sess.refresh(receipt) - - msg = { - "agent_addr": receipt_data.agentAddress.lower(), - "avatar_addr": receipt_data.avatarAddress.lower(), - "product_id": product.id, - "uuid": str(receipt.uuid), - "planet_id": receipt_data.planetId.decode('utf-8'), - } - - resp = sqs.send_message(QueueUrl=SQS_URL, MessageBody=json.dumps(msg)) - logger.debug(f"message [{resp['MessageId']}] sent to SQS.") - - return receipt - - @router.get("/status", response_model=Dict[UUID, Optional[ReceiptDetailSchema]]) def purchase_status(uuid: Annotated[List[UUID], Query()] = ..., sess=Depends(session)): """ diff --git a/iap/iap_cdk_stack.py b/iap/iap_cdk_stack.py index 6acb2b2c..cab8814a 100644 --- a/iap/iap_cdk_stack.py +++ b/iap/iap_cdk_stack.py @@ -98,8 +98,6 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: "APPLE_KEY_ID": config.apple_key_id, "APPLE_ISSUER_ID": config.apple_issuer_id, "HEADLESS": config.headless, - "ODIN_GQL_URL": config.odin_gql_url, - "HEIMDALL_GQL_URL": config.heimdall_gql_url, "CDN_HOST": config.cdn_host, "PLANET_URL": config.planet_url, "BRIDGE_DATA": config.bridge_data, diff --git a/iap/schemas/product.py b/iap/schemas/product.py index c2202218..f02c3ac0 100644 --- a/iap/schemas/product.py +++ b/iap/schemas/product.py @@ -2,22 +2,20 @@ from pydantic import BaseModel as BaseSchema, model_validator -from common.enums import ProductRarity, ProductAssetUISize +from common.enums import Currency, ProductRarity, ProductAssetUISize class SimpleProductSchema(BaseSchema): name: str order: int - google_sku: str = "" - apple_sku: str = "" - is_free: bool + google_sku: str + apple_sku: str # product_type: ProductType daily_limit: Optional[int] = None weekly_limit: Optional[int] = None account_limit: Optional[int] = None active: bool buyable: bool = True - required_level: Optional[int] = None class Config: from_attributes = True diff --git a/iap/schemas/receipt.py b/iap/schemas/receipt.py index 68353406..78b79ef5 100644 --- a/iap/schemas/receipt.py +++ b/iap/schemas/receipt.py @@ -58,26 +58,6 @@ def json_data(self) -> dict: return data -@dataclass -class FreeReceiptSchema: - sku: str - agentAddress: str - avatarAddress: str - store: Store - planetId: Union[str, PlanetID] - - def __post_init__(self): - # Reformat address to starts with `0x` - if self.agentAddress: - self.agentAddress = format_addr(self.agentAddress) - if self.avatarAddress: - self.avatarAddress = format_addr(self.avatarAddress) - - # Parse planet - if isinstance(self.planetId, str): - self.planetId = PlanetID(bytes(self.planetId, 'utf-8')) - - @dataclass class ReceiptSchema: data: Union[str, Dict, object] diff --git a/tests/lib9c/models/test_address.py b/tests/lib9c/models/test_address.py deleted file mode 100644 index dd247ed8..00000000 --- a/tests/lib9c/models/test_address.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest - -from common.lib9c.models.address import Address - - -@pytest.mark.parametrize("addr", - ["0xa5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27D", - "0xb3cbca0e64aeb4b5b861047fe1db5a1bec1c241f", - "a5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27D", - "b3cbca0e64aeb4b5b861047fe1db5a1bec1c241f", - ]) -def test_address(addr): - address = Address(addr) - assert len(address.raw) == 20 - if addr.startswith("0x"): - assert address.raw == bytes.fromhex(addr[2:]) - assert address.long_format == addr.lower() - else: - assert address.raw == bytes.fromhex(addr) - assert address.short_format == addr.lower() - - -@pytest.mark.parametrize("addr", - [ - "0xa5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27X", # Invalid character - "a5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27X", # Invalid character - "0xa5f7e0bd63AD2749D66380f36Eb33Fe0ba50A2", # Length - "a5f7e0bd63AD2749D66380f36Eb33Fe0ba50A2", # Length - ]) -def test_address_error(addr): - with pytest.raises(ValueError) as e: - Address(addr) diff --git a/tests/lib9c/models/test_currency.py b/tests/lib9c/models/test_currency.py deleted file mode 100644 index d2630d62..00000000 --- a/tests/lib9c/models/test_currency.py +++ /dev/null @@ -1,58 +0,0 @@ -import pytest - -from common.lib9c.models.address import Address -from common.lib9c.models.currency import Currency - -TEST_DATASET = [ - ("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False, - b'du13:decimalPlaces1:\x02u7:minterslu40:47d082a115c63e7b58b1532d20e631538eafaddeeu6:tickeru3:NCGe'), - ("CRYSTAL", 18, None, False, b'du13:decimalPlaces1:\x12u7:mintersnu6:tickeru7:CRYSTALe'), - ("GARAGE", 18, None, True, b'du13:decimalPlaces1:\x12u7:mintersnu6:tickeru6:GARAGEu20:totalSupplyTrackablete'), - ("OTHER", 0, None, False, b'du13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERe'), - ("OTHER", 0, [], False, b'du13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERe'), - ("OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0"], False, - b'du13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0eu6:tickeru5:OTHERe'), - ("OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0", "0x3C32731b77C5D99D186572E5ce5d6AA93A8853dC"], False, - b'du13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0u40:3c32731b77c5d99d186572e5ce5d6aa93a8853dceu6:tickeru5:OTHERe'), -] - - -@pytest.mark.parametrize("test_data", TEST_DATASET) -def test_currency(test_data): - ticker, decimal_places, minters, total_supply_trackable, _ = test_data - currency = Currency(ticker, decimal_places, minters, total_supply_trackable) - assert currency.ticker == ticker - assert currency.decimal_places == decimal_places - assert currency.minters == ([Address(x) for x in minters] if minters else None) - if total_supply_trackable: - assert currency.total_supply_trackable is total_supply_trackable - - -def test_well_known_currency(): - test_ncg = Currency.NCG() - expected_ncg = Currency("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False) - assert test_ncg == expected_ncg - - test_crystal = Currency.CRYSTAL() - expected_crystal = Currency("CRYSTAL", 18, None, False) - assert test_crystal == expected_crystal - - -@pytest.mark.parametrize("test_data", TEST_DATASET) -def test_plain_value(test_data): - ticker, decimal_places, minters, total_supply_trackable, _ = test_data - currency = Currency(ticker, decimal_places, minters, total_supply_trackable) - plain_value = currency.plain_value - assert plain_value["ticker"] == ticker - assert plain_value["decimalPlaces"] == chr(decimal_places).encode() - assert plain_value["minters"] == ( - [x[2:].lower() if x.startswith("0x") else x.lower() for x in minters] if minters else None) - if total_supply_trackable: - assert plain_value["totalSupplyTrackable"] == total_supply_trackable - - -@pytest.mark.parametrize("test_data", TEST_DATASET) -def test_serialized_plain_value(test_data): - ticker, decimal_places, minters, total_supply_trackable, serialized = test_data - currency = Currency(ticker, decimal_places, minters, total_supply_trackable) - assert currency.serialized_plain_value == serialized diff --git a/tests/lib9c/models/test_fungible_asset_value.py b/tests/lib9c/models/test_fungible_asset_value.py deleted file mode 100644 index 56619a9d..00000000 --- a/tests/lib9c/models/test_fungible_asset_value.py +++ /dev/null @@ -1,70 +0,0 @@ -import pytest - -from common.lib9c.models.currency import Currency -from common.lib9c.models.fungible_asset_value import FungibleAssetValue - -TEST_DATASET = [ - ("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False, 0, - b'ldu13:decimalPlaces1:\x02u7:minterslu40:47d082a115c63e7b58b1532d20e631538eafaddeeu6:tickeru3:NCGei0ee'), - ("CRYSTAL", 18, None, False, 0, b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru7:CRYSTALei0ee'), - ("GARAGE", 18, None, True, 0, - b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru6:GARAGEu20:totalSupplyTrackabletei0ee'), - ("OTHER", 0, None, False, 0, b'ldu13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERei0ee'), - ( - "OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0", "0x3C32731b77C5D99D186572E5ce5d6AA93A8853dC"], False, - 0, - b'ldu13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0u40:3c32731b77c5d99d186572e5ce5d6aa93a8853dceu6:tickeru5:OTHERei0ee' - ), - ("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False, 1, - b'ldu13:decimalPlaces1:\x02u7:minterslu40:47d082a115c63e7b58b1532d20e631538eafaddeeu6:tickeru3:NCGei100ee'), - ("CRYSTAL", 18, None, False, 1, b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru7:CRYSTALei1000000000000000000ee'), - ("GARAGE", 18, None, True, 1, - b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru6:GARAGEu20:totalSupplyTrackabletei1000000000000000000ee'), - ("OTHER", 0, None, False, 1, b'ldu13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERei1ee'), - ( - "OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0", "0x3C32731b77C5D99D186572E5ce5d6AA93A8853dC"], False, - 1, - b'ldu13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0u40:3c32731b77c5d99d186572e5ce5d6aa93a8853dceu6:tickeru5:OTHERei1ee' - ), -] - - -@pytest.mark.parametrize("test_data", TEST_DATASET) -def test_fav(test_data): - ticker, decimal_places, minters, total_supply_trackable, amount, _ = test_data - currency = Currency(ticker, decimal_places, minters, total_supply_trackable) - fav = FungibleAssetValue(currency, amount) - assert fav.currency == currency - assert fav.amount == amount - - -@pytest.mark.parametrize("test_data", TEST_DATASET) -def test_fav_from_data(test_data): - ticker, decimal_places, minters, total_supply_trackable, amount, _ = test_data - fav = FungibleAssetValue.from_raw_data(ticker, decimal_places, minters, total_supply_trackable, amount) - expected_currency = Currency(ticker, decimal_places, minters, total_supply_trackable) - assert fav.currency == expected_currency - assert fav.amount == amount - - -@pytest.mark.parametrize("test_data", TEST_DATASET) -def test_plain_value(test_data): - ticker, decimal_places, minters, total_supply_trackable, amount, _ = test_data - fav = FungibleAssetValue.from_raw_data(ticker, decimal_places, minters, total_supply_trackable, amount) - plain_value = fav.plain_value - assert plain_value[0]["ticker"] == ticker - assert plain_value[0]["decimalPlaces"] == chr(decimal_places).encode() - assert plain_value[0]["minters"] == ([x[2:].lower() if x.startswith("0x") else x.lower() for x in minters] - if minters else None) - if total_supply_trackable: - assert plain_value[0]["totalSupplyTrackable"] is True - else: - assert "totalSupplyTrackable" not in plain_value[0] - assert plain_value[1] == amount * max(1, 10 ** decimal_places) - - -@pytest.mark.parametrize("test_data", TEST_DATASET) -def test_serialized_plain_value(test_data): - ticker, decimal_places, minters, total_supply_trackable, amount, expected = test_data - fav = FungibleAssetValue.from_raw_data(ticker, decimal_places, minters, total_supply_trackable, amount) - assert fav.serialized_plain_value == expected diff --git a/tests/lib9c/test_currency.py b/tests/lib9c/test_currency.py new file mode 100644 index 00000000..72a192e3 --- /dev/null +++ b/tests/lib9c/test_currency.py @@ -0,0 +1,30 @@ +import bencodex +from common.lib9c.currency import Currency + + +def test_crystal(): + crystal = Currency.to_currency("crystal") + assert crystal["decimalPlaces"] == b'\x12' + assert crystal["minters"] == None + assert crystal["ticker"] == "CRYSTAL" + +def test_garage(): + garage = Currency.to_currency("garage") + assert garage["decimalPlaces"] == b'\x12' + assert garage["minters"] == None + assert garage["ticker"] == "GARAGE" + assert garage["totalSupplyTrackable"] == True + +def test_other(): + other = Currency.to_currency("other") + assert other["decimalPlaces"] == b'\x00' + assert other["minters"] == None + assert other["ticker"] == "OTHER" + +def test_serialize(): + crystal = Currency.to_currency("crystal") + assert Currency.serialize(crystal) == bencodex.dumps({'decimalPlaces': b'\x12', 'minters': None, 'ticker': 'CRYSTAL'}) + garage = Currency.to_currency("garage") + assert Currency.serialize(garage) == bencodex.dumps({'decimalPlaces': b'\x12', 'minters': None, 'ticker': 'GARAGE', 'totalSupplyTrackable': True}) + other = Currency.to_currency("other") + assert Currency.serialize(other) == bencodex.dumps({'decimalPlaces': b'\x00', 'minters': None, 'ticker': 'OTHER'}) diff --git a/tests/lib9c/test_fungible_asset.py b/tests/lib9c/test_fungible_asset.py new file mode 100644 index 00000000..2536966d --- /dev/null +++ b/tests/lib9c/test_fungible_asset.py @@ -0,0 +1,13 @@ +from common.lib9c.fungible_asset import FungibleAsset + + +def test_to_fungible_asset(): + assert FungibleAsset.to_fungible_asset("CRYSTAL", 100, 18) == [{"decimalPlaces": b'\x12', "minters": None, "ticker": "CRYSTAL"}, 100 * 10**18] + assert FungibleAsset.to_fungible_asset("GARAGE", 1, 18) == [{"decimalPlaces": b'\x12', "minters": None, "ticker": "GARAGE", "totalSupplyTrackable": True}, 1 * 10**18] + assert FungibleAsset.to_fungible_asset("OTHER", 999, 0) == [{"decimalPlaces": b'\x00', "minters": None, "ticker": "OTHER"}, 999] + + +def test_serialize(): + assert FungibleAsset.serialize(FungibleAsset.to_fungible_asset("CRYSTAL", 100, 18)).hex() == "6c35323a647531333a646563696d616c506c61636573313a1275373a6d696e746572736e75363a7469636b657275373a4352595354414c65693130303030303030303030303030303030303030306565" + assert FungibleAsset.serialize(FungibleAsset.to_fungible_asset("GARAGE", 1, 18)).hex() == "6c37363a647531333a646563696d616c506c61636573313a1275373a6d696e746572736e75363a7469636b657275363a4741524147457532303a746f74616c537570706c79547261636b61626c65746569313030303030303030303030303030303030306565" + assert FungibleAsset.serialize(FungibleAsset.to_fungible_asset("OTHER", 999, 0)).hex() == "6c35303a647531333a646563696d616c506c61636573313a0075373a6d696e746572736e75363a7469636b657275353a4f5448455265693939396565" diff --git a/tests/utils/test_actions.py b/tests/utils/test_actions.py index 31378fa1..62f843c0 100644 --- a/tests/utils/test_actions.py +++ b/tests/utils/test_actions.py @@ -25,9 +25,9 @@ def test_create_unload_my_garages_action_plain_value(): "count": 200 } ] - _id = "6e747fecdc33374a81fdc42b99d0d4f3" + id = "6e747fecdc33374a81fdc42b99d0d4f3" memo = '["0x9eaac29af78f88f8dbb5fad976c683e92f25fdb3", "0x25b4ce744b7e0150ef9999b6eff5010b6d4a164a", "{\\"iap\\": {\\"g_sku\\": \\"g_pkg_launching1\\", \\"a_sku\\": \\"a_pkg_launching1\\"}}"]' - plain_value = create_unload_my_garages_action_plain_value(_id, fav_data, avatar_addr, item_data, memo) + plain_value = create_unload_my_garages_action_plain_value(id, fav_data, avatar_addr, item_data, memo) expected = {'type_id': 'unload_from_my_garages', 'values': {'id': b'nt\x7f\xec\xdc37J\x81\xfd\xc4+\x99\xd0\xd4\xf3', 'l': [b'A\xae\xfeL\xdd\xfbW\xc9\xdf\xfdI\x0e\x17\xe5qp\\Y=\xdc', [[b'\x1c*\xe9s\x80\xcf\xb4\xf72\x04\x9eEOm\x9a%\xd4\x96|o', [{'decimalPlaces': b'\x12', 'minters': None, 'ticker': 'CRYSTAL'}, 1500000000000000000000000]]], [[b'9\x91\xe0M\xd8\x08\xdc\x0b\xc2K!\xf5\xad\xb7\xbf\x19\x971/\x87\x00\xda\xf13K\xf3I6\xe8\xa0\x81:', 8000], [b'\xf8\xfa\xf9,\x9c\r\x0e\x8e\x06iCa\xea\x87\xbf\xc8\xb2\x9a\x8a\xe8\xde\x93\x04K\x98G\nWcn\xd0\xe0', 200]], '["0x9eaac29af78f88f8dbb5fad976c683e92f25fdb3", "0x25b4ce744b7e0150ef9999b6eff5010b6d4a164a", "{\\"iap\\": {\\"g_sku\\": \\"g_pkg_launching1\\", \\"a_sku\\": \\"a_pkg_launching1\\"}}"]']}} expected_hex = "6475373a747970655f69647532323a756e6c6f61645f66726f6d5f6d795f6761726167657375363a76616c7565736475323a696431363a6e747fecdc33374a81fdc42b99d0d4f375313a6c6c32303a41aefe4cddfb57c9dffd490e17e571705c593ddc6c6c32303a1c2ae97380cfb4f732049e454f6d9a25d4967c6f6c647531333a646563696d616c506c61636573313a1275373a6d696e746572736e75363a7469636b657275373a4352595354414c656931353030303030303030303030303030303030303030303030656565656c6c33323a3991e04dd808dc0bc24b21f5adb7bf1997312f8700daf1334bf34936e8a0813a693830303065656c33323af8faf92c9c0d0e8e06694361ea87bfc8b29a8ae8de93044b98470a57636ed0e069323030656565753137333a5b22307839656161633239616637386638386638646262356661643937366336383365393266323566646233222c2022307832356234636537343462376530313530656639393939623665666635303130623664346131363461222c20227b5c226961705c223a207b5c22675f736b755c223a205c22675f706b675f6c61756e6368696e67315c222c205c22615f736b755c223a205c22615f706b675f6c61756e6368696e67315c227d7d225d656565" diff --git a/worker/worker/handler.py b/worker/worker/handler.py index e5c753c0..6f968576 100644 --- a/worker/worker/handler.py +++ b/worker/worker/handler.py @@ -117,7 +117,7 @@ def process(sess: Session, message: SQSMessageRecord, nonce: int = None) -> Tupl } for x in product.fungible_item_list] unload_from_garage = create_unload_my_garages_action_plain_value( - _id=uuid.uuid1().hex, + id=uuid.uuid1().hex, fav_data=fav_data, avatar_addr=avatar_address, item_data=item_data, @@ -129,7 +129,6 @@ def process(sess: Session, message: SQSMessageRecord, nonce: int = None) -> Tupl public_key=account.pubkey.hex(), address=account.address, nonce=nonce, plain_value=unload_from_garage, timestamp=datetime.datetime.utcnow() + datetime.timedelta(days=1) ) - signature = account.sign_tx(unsigned_tx) signed_tx = append_signature_to_unsigned_tx(unsigned_tx, signature) return gql.stage(signed_tx), nonce, signed_tx diff --git a/worker/worker/status_monitor.py b/worker/worker/status_monitor.py index fde4f504..f1b419d6 100644 --- a/worker/worker/status_monitor.py +++ b/worker/worker/status_monitor.py @@ -12,7 +12,6 @@ from common.utils.aws import fetch_secrets from common.utils.receipt import PlanetID -STAGE = os.environ.get("STAGE") DB_URI = os.environ.get("DB_URI") db_password = fetch_secrets(os.environ.get("REGION_NAME"), os.environ.get("SECRET_ARN"))["password"] DB_URI = DB_URI.replace("[DB_PASSWORD]", db_password) @@ -29,17 +28,6 @@ "48e50ecd6d1aa2689fd349c1f0611e6cc1e9c4c74ec4de9d4637ec7b78617308": "Golden Meat (800202)", } -VIEW_ORDER = ( - "CRYSTAL", - FUNGIBLE_DICT["3991e04dd808dc0bc24b21f5adb7bf1997312f8700daf1334bf34936e8a0813a"], # Hourglass - FUNGIBLE_DICT["00dfffe23964af9b284d121dae476571b7836b8d9e2e5f510d92a840fecc64fe"], # AP Potion - "RUNE_GOLDENLEAF", - FUNGIBLE_DICT["f8faf92c9c0d0e8e06694361ea87bfc8b29a8ae8de93044b98470a57636ed0e0"], # Golden Dust - FUNGIBLE_DICT["48e50ecd6d1aa2689fd349c1f0611e6cc1e9c4c74ec4de9d4637ec7b78617308"], # Golden Meat - FUNGIBLE_DICT["1a755098a2bc0659a063107df62e2ff9b3cdaba34d96b79519f504b996f53820"], # Silver Dust - "SOULSTONE_1001", "SOULSTONE_1002", "SOULSTONE_1003", "SOULSTONE_1004", -) - engine = create_engine(DB_URI) @@ -160,21 +148,13 @@ def check_garage(): fav_data = data["garageBalances"] item_data = data["fungibleItemGarages"] - result_dict = {} - + msg = [] for fav in fav_data: - result_dict[fav["currency"]["ticker"]] = fav["quantity"].split(".")[0] + msg.append(create_block(f"{fav['currency']['ticker']} : {int(fav['quantity'].split('.')[0]):,}")) for item in item_data: - result_dict[FUNGIBLE_DICT[item["fungibleItemId"]]] = item["count"] - - msg = [] - for key in VIEW_ORDER: - result = result_dict.pop(key) - msg.append(create_block(f"{key} : {int(result):,}")) - for key, result in result_dict.items(): - msg.append(create_block(f"{key} : {int(result):,}")) + msg.append(create_block(f"{FUNGIBLE_DICT[item['fungibleItemId']]} : {item['count']:,}")) - send_message(IAP_GARAGE_WEBHOOK_URL, f"[NineChronicles.IAP] Daily Garage Report - {STAGE}", msg) + send_message(IAP_GARAGE_WEBHOOK_URL, "[NineChronicles.IAP] Daily Garage Report", msg) def handle(event, context): From 517ff07d8b45425c6969d95f2c76d13d49380ca1 Mon Sep 17 00:00:00 2001 From: hyeon Date: Fri, 26 Jan 2024 17:48:44 +0900 Subject: [PATCH 19/20] FAV receives Decimal and returns int in plain_value --- common/lib9c/models/fungible_asset_value.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/common/lib9c/models/fungible_asset_value.py b/common/lib9c/models/fungible_asset_value.py index 6ded1705..cec82291 100644 --- a/common/lib9c/models/fungible_asset_value.py +++ b/common/lib9c/models/fungible_asset_value.py @@ -1,5 +1,6 @@ from __future__ import annotations +from decimal import Decimal from typing import Dict, List, Optional, Any import bencodex @@ -8,7 +9,7 @@ class FungibleAssetValue: - def __init__(self, currency: Currency, amount: float): + def __init__(self, currency: Currency, amount: Decimal): self.currency = currency self.amount = amount @@ -19,7 +20,7 @@ def __eq__(self, other: FungibleAssetValue): def from_raw_data( cls, ticker: str, decimal_places: int, minters: Optional[List[str]] = None, total_supply_trackable: bool = False, - amount: float = 0 + amount: Decimal = Decimal("0") ): return cls( Currency(ticker, decimal_places, minters, total_supply_trackable), @@ -27,8 +28,8 @@ def from_raw_data( ) @property - def plain_value(self) -> List[Dict[str, Any] | float]: - return [self.currency.plain_value, self.amount * max(1, 10 ** self.currency.decimal_places)] + def plain_value(self) -> List[Dict[str, Any] | int]: + return [self.currency.plain_value, int(self.amount * max(1, 10 ** self.currency.decimal_places))] @property def serialized_plain_value(self) -> bytes: From 12a70d3bd147a5c49efdcdcb9b7507554994a6b0 Mon Sep 17 00:00:00 2001 From: "ulismoon (hyeon)" Date: Mon, 29 Jan 2024 14:27:33 +0900 Subject: [PATCH 20/20] Revert "Revert "0.10.0"" --- .github/workflows/deploy.yml | 4 + .github/workflows/main.yml | 11 +- .github/workflows/synth.yml | 2 + common/__init__.py | 2 + common/_graphql.py | 22 ++++ .../90ff6ac09fe5_add_required_level_column.py | 59 +++++++++ .../a2df38682595_add_is_free_column.py | 30 +++++ common/enums.py | 5 + common/lib9c/currency.py | 31 ----- common/lib9c/fungible_asset.py | 16 --- common/lib9c/models/address.py | 24 ++++ common/lib9c/models/currency.py | 62 +++++++++ common/lib9c/models/fungible_asset_value.py | 35 +++++ common/models/product.py | 2 + common/utils/actions.py | 12 +- iap/api/purchase.py | 121 +++++++++++++++++- iap/iap_cdk_stack.py | 2 + iap/schemas/product.py | 8 +- iap/schemas/receipt.py | 20 +++ tests/lib9c/models/test_address.py | 32 +++++ tests/lib9c/models/test_currency.py | 58 +++++++++ .../lib9c/models/test_fungible_asset_value.py | 70 ++++++++++ tests/lib9c/test_currency.py | 30 ----- tests/lib9c/test_fungible_asset.py | 13 -- tests/utils/test_actions.py | 4 +- worker/worker/handler.py | 3 +- worker/worker/status_monitor.py | 28 +++- 27 files changed, 593 insertions(+), 113 deletions(-) create mode 100644 common/alembic/versions/90ff6ac09fe5_add_required_level_column.py create mode 100644 common/alembic/versions/a2df38682595_add_is_free_column.py delete mode 100644 common/lib9c/currency.py delete mode 100644 common/lib9c/fungible_asset.py create mode 100644 common/lib9c/models/address.py create mode 100644 common/lib9c/models/currency.py create mode 100644 common/lib9c/models/fungible_asset_value.py create mode 100644 tests/lib9c/models/test_address.py create mode 100644 tests/lib9c/models/test_currency.py create mode 100644 tests/lib9c/models/test_fungible_asset_value.py delete mode 100644 tests/lib9c/test_currency.py delete mode 100644 tests/lib9c/test_fungible_asset.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d47c9434..651d4a6a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -130,6 +130,8 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} + ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} + HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} @@ -163,6 +165,8 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} + ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} + HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 74ede92d..6bd6d7a9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - development + - internal - release/* - main pull_request: @@ -16,7 +17,7 @@ jobs: test: uses: ./.github/workflows/test.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || (startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development') }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} secrets: APPLE_CREDENTIAL: ${{ secrets.APPLE_CREDENTIAL }} APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} @@ -28,14 +29,14 @@ jobs: build_frontend: uses: ./.github/workflows/build_frontend.yml with: - environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || (startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development') }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} secrets: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} synth: uses: ./.github/workflows/synth.yml with: - environment: ${{ startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development' }} + environment: ${{ github.ref == 'refs/heads/main' && 'mainnet' || ((github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development') }} secrets: ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -58,11 +59,11 @@ jobs: deploy_without_approval: # This is for development / internal deployment - if: ${{ github.ref == 'refs/heads/development' || startsWith(github.ref, 'refs/heads/release') }} + if: ${{ github.ref == 'refs/heads/development' || github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release') }} needs: [ "test", "build_frontend", "synth" ] uses: ./.github/workflows/deploy.yml with: - environment: ${{ startsWith(github.ref, 'refs/heads/release') && 'internal' || 'development' }} + environment: ${{ (github.ref == 'refs/heads/internal' || startsWith(github.ref, 'refs/heads/release')) && 'internal' || 'development' }} secrets: ACCOUNT_ID: ${{ secrets.ACCOUNT_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/.github/workflows/synth.yml b/.github/workflows/synth.yml index 2a2d4a4e..0d5a1358 100644 --- a/.github/workflows/synth.yml +++ b/.github/workflows/synth.yml @@ -117,6 +117,8 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} HEADLESS: ${{ vars.HEADLESS }} + ODIN_GQL_URL: ${{ vars.ODIN_GQL_URL }} + HEIMDALL_GQL_URL: ${{ vars.HEIMDALL_GQL_URL }} KMS_KEY_ID: ${{ secrets.KMS_KEY_ID }} GOOGLE_CREDENTIAL: ${{ secrets.GOOGLE_CREDENTIAL }} GOOGLE_PACKAGE_NAME: ${{ vars.GOOGLE_PACKAGE_NAME }} diff --git a/common/__init__.py b/common/__init__.py index 33b7f0d4..9a45b7a5 100644 --- a/common/__init__.py +++ b/common/__init__.py @@ -36,6 +36,8 @@ class Config: account_id: str region_name: str cdn_host: str + odin_gql_url: str + heimdall_gql_url: str # Multiplanetary planet_url: str diff --git a/common/_graphql.py b/common/_graphql.py index 1ed228a2..af6ddb78 100644 --- a/common/_graphql.py +++ b/common/_graphql.py @@ -80,6 +80,28 @@ def _unload_from_garage(self, pubkey: bytes, nonce: int, **kwargs) -> bytes: result = self.execute(query) return bytes.fromhex(result["actionTxQuery"]["unloadFromMyGarages"]) + def _claim_items(self, pubkey: bytes, nonce: int, **kwargs) -> bytes: + ts = kwargs.get("timestamp", (datetime.datetime.utcnow()+datetime.timedelta(days=1)).isoformat()) + claim_data = kwargs.get("claim_data") + memo = kwargs.get("memo") + + query = dsl_gql( + DSLQuery( + self.ds.StandaloneQuery.actionTxQuery.args( + publicKey=pubkey.hex(), + nonce=nonce, + timestamp=ts, + ).select( + self.ds.ActionTxQuery.claimItems.args( + claimData=claim_data, + memo=memo, + ) + ) + ) + ) + result = self.execute(query) + return bytes.fromhex(result["actionTxQuery"]["unloadFromMyGarages"]) + def _transfer_asset(self, pubkey: bytes, nonce: int, **kwargs) -> bytes: ts = kwargs.get("timestamp", (datetime.datetime.utcnow()+datetime.timedelta(days=1)).isoformat()) sender = kwargs.get("sender") diff --git a/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py b/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py new file mode 100644 index 00000000..9f2777f1 --- /dev/null +++ b/common/alembic/versions/90ff6ac09fe5_add_required_level_column.py @@ -0,0 +1,59 @@ +"""Add required_level column + +Revision ID: 90ff6ac09fe5 +Revises: a2df38682595 +Create Date: 2024-01-22 12:31:38.823682 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '90ff6ac09fe5' +down_revision = 'a2df38682595' +branch_labels = None +depends_on = None + +old_enum = ("INIT", "VALIDATION_REQUEST", "VALID", "REFUNDED_BY_ADMIN", "INVALID", "REFUNDED_BY_BUYER", + "PURCHASE_LIMIT_EXCEED", "TIME_LIMIT", "UNKNOWN") +new_enum = sorted(old_enum + ("REQUIRED_LEVEL",)) + +old_status = sa.Enum(*old_enum, name="receiptstatus") +new_status = sa.Enum(*new_enum, name="receiptstatus") +tmp_status = sa.Enum(*new_enum, name="_receiptstatus") + +receipt_table = sa.sql.table( + "receipt", + sa.Column("status", new_status, nullable=False) +) + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + tmp_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE _receiptstatus USING status::text::_receiptstatus") + old_status.drop(op.get_bind(), checkfirst=False) + new_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE receiptstatus USING status::text::receiptstatus") + tmp_status.drop(op.get_bind(), checkfirst=False) + + op.add_column('product', sa.Column('required_level', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('product', 'required_level') + + op.execute(receipt_table.update() + .where(receipt_table.c.status.in_(['REQUIRED_LEVEL'])) + .values(status="INVALID") + ) + tmp_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE _receiptstatus USING status::text::_receiptstatus") + new_status.drop(op.get_bind(), checkfirst=False) + old_status.create(op.get_bind(), checkfirst=False) + op.execute("ALTER TABLE receipt ALTER COLUMN status TYPE receiptstatus USING status::text::receiptstatus") + tmp_status.drop(op.get_bind(), checkfirst=False) + # ### end Alembic commands ### diff --git a/common/alembic/versions/a2df38682595_add_is_free_column.py b/common/alembic/versions/a2df38682595_add_is_free_column.py new file mode 100644 index 00000000..0f2a7bad --- /dev/null +++ b/common/alembic/versions/a2df38682595_add_is_free_column.py @@ -0,0 +1,30 @@ +"""Add is_free column + +Revision ID: a2df38682595 +Revises: 2822c456abc3 +Create Date: 2024-01-18 13:55:23.731546 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a2df38682595' +down_revision = '2822c456abc3' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('product', sa.Column('is_free', sa.Boolean(), nullable=True, default=False)) + op.execute("UPDATE product SET is_free=FALSE") + op.alter_column("product", "is_free", nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('product', 'is_free') + # ### end Alembic commands ### diff --git a/common/enums.py b/common/enums.py index 744df567..96e15cae 100644 --- a/common/enums.py +++ b/common/enums.py @@ -131,6 +131,10 @@ class ReceiptStatus(IntEnum): Purchase timestamp is not between target product's open/close timestamp. This purchase is executed in appstore, so admin should refund this purchase in manual. + - **95: `REQUIRED_LEVEL`** + + Does not met required level to get this product. + - **99: `UNKNOWN`** An unhandled error case. This is reserve to catch all other errors. @@ -145,6 +149,7 @@ class ReceiptStatus(IntEnum): REFUNDED_BY_BUYER = 92 PURCHASE_LIMIT_EXCEED = 93 TIME_LIMIT = 94 + REQUIRED_LEVEL = 95 UNKNOWN = 99 diff --git a/common/lib9c/currency.py b/common/lib9c/currency.py deleted file mode 100644 index 2409476a..00000000 --- a/common/lib9c/currency.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Dict, Any, Union - -import bencodex - -class Currency(): - - @staticmethod - def to_currency(ticker: str) -> Dict[str, Union[str, int, None]]: - if ticker.lower() == "crystal": - return { - "decimalPlaces": b'\x12', - "minters": None, - "ticker": "CRYSTAL", - } - elif ticker.lower() == "garage": - return { - "decimalPlaces": b'\x12', - "minters": None, - "ticker": "GARAGE", - "totalSupplyTrackable": True, - } - else: - return { - "decimalPlaces": b'\x00', - "minters": None, - "ticker": ticker.upper(), - } - - @staticmethod - def serialize(currency: Dict[str, Union[str, int, None]]) -> bytes: - return bencodex.dumps(currency) diff --git a/common/lib9c/fungible_asset.py b/common/lib9c/fungible_asset.py deleted file mode 100644 index ab56c6f9..00000000 --- a/common/lib9c/fungible_asset.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Dict, Union, List - -import bencodex - -from common.lib9c.currency import Currency - - -class FungibleAsset(): - - @staticmethod - def to_fungible_asset(ticker: str, amount: int, decimalPlaces: int) -> List[Union[Dict[str, Union[str, int, None]], int]]: - return [Currency.to_currency(ticker), amount * max(1, 10 ** decimalPlaces)] - - @staticmethod - def serialize(fungible_asset: List[Union[Dict[str, Union[str, int, None]], int]]) -> bytes: - return bencodex.dumps([Currency.serialize(fungible_asset[0]), fungible_asset[1]]) diff --git a/common/lib9c/models/address.py b/common/lib9c/models/address.py new file mode 100644 index 00000000..c8086946 --- /dev/null +++ b/common/lib9c/models/address.py @@ -0,0 +1,24 @@ +from __future__ import annotations + + +class Address: + def __init__(self, addr: str): + if addr.startswith("0x"): + if len(addr) != 42: + raise ValueError("Address with 0x prefix must have exact 42 chars.") + self.raw = bytes.fromhex(addr[2:]) + else: + if len(addr) != 40: + raise ValueError("Address without 0x prefix must have exact 40 chars.") + self.raw = bytes.fromhex(addr) + + @property + def long_format(self): + return f"0x{self.raw.hex()}" + + @property + def short_format(self): + return self.raw.hex() + + def __eq__(self, other: Address): + return self.raw == other.raw diff --git a/common/lib9c/models/currency.py b/common/lib9c/models/currency.py new file mode 100644 index 00000000..2baacf4c --- /dev/null +++ b/common/lib9c/models/currency.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Dict, List, Optional, Any + +import bencodex + +from common.lib9c.models.address import Address + + +class Currency: + """ + # Currency + --- + Lib9c Currency model which has ticker, minters, decimal_places, total_supply_trackable. + `minters` will be automatically sanitized to `None` if empty list provided. + """ + + def __init__(self, ticker: str, decimal_places: int, minters: Optional[List[str]] = None, + total_supply_trackable: bool = False): + self.ticker = ticker + self.minters = [Address(x) for x in minters] if minters else None + self.decimal_places = decimal_places + self.total_supply_trackable = total_supply_trackable + + def __eq__(self, other: Currency): + return ( + self.ticker == other.ticker and + self.minters == other.minters and + self.decimal_places == other.decimal_places and + self.total_supply_trackable == other.total_supply_trackable + ) + + @classmethod + def NCG(cls): + return cls( + ticker="NCG", + minters=["47d082a115c63e7b58b1532d20e631538eafadde"], + decimal_places=2 + ) + + @classmethod + def CRYSTAL(cls): + return cls( + ticker="CRYSTAL", + minters=None, + decimal_places=18 + ) + + @property + def plain_value(self) -> Dict[str, Any]: + value = { + "ticker": self.ticker, + "decimalPlaces": chr(self.decimal_places).encode(), + "minters": [x.short_format for x in self.minters] if self.minters else None + } + if self.total_supply_trackable: + value["totalSupplyTrackable"] = True + return value + + @property + def serialized_plain_value(self) -> bytes: + return bencodex.dumps(self.plain_value) diff --git a/common/lib9c/models/fungible_asset_value.py b/common/lib9c/models/fungible_asset_value.py new file mode 100644 index 00000000..6ded1705 --- /dev/null +++ b/common/lib9c/models/fungible_asset_value.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Dict, List, Optional, Any + +import bencodex + +from common.lib9c.models.currency import Currency + + +class FungibleAssetValue: + def __init__(self, currency: Currency, amount: float): + self.currency = currency + self.amount = amount + + def __eq__(self, other: FungibleAssetValue): + return self.currency == other.currency and self.amount == other.amount + + @classmethod + def from_raw_data( + cls, + ticker: str, decimal_places: int, minters: Optional[List[str]] = None, total_supply_trackable: bool = False, + amount: float = 0 + ): + return cls( + Currency(ticker, decimal_places, minters, total_supply_trackable), + amount=amount + ) + + @property + def plain_value(self) -> List[Dict[str, Any] | float]: + return [self.currency.plain_value, self.amount * max(1, 10 ** self.currency.decimal_places)] + + @property + def serialized_plain_value(self) -> bytes: + return bencodex.dumps(self.plain_value) diff --git a/common/models/product.py b/common/models/product.py index 057bbd1e..c317ff59 100644 --- a/common/models/product.py +++ b/common/models/product.py @@ -60,6 +60,8 @@ class Product(AutoIdMixin, TimeStampMixin, Base): google_sku = Column(Text, doc="SKU ID of google play store") apple_sku = Column(Text, doc="SKU ID of apple appstore") # product_type = Column(ENUM(ProductType), default=ProductType.SINGLE, nullable=False) + is_free = Column(Boolean, nullable=False, default=False, doc="Flag to set this product as free") + required_level = Column(Integer, nullable=True, default=None, doc="Required avatar level to purchase this product") daily_limit = Column(Integer, nullable=True, doc="Purchase limit in 24 hours") weekly_limit = Column(Integer, nullable=True, doc="Purchase limit in 7 days (24 * 7 hours)") account_limit = Column(Integer, nullable=True, doc="Purchase limit for each account (in lifetime)") diff --git a/common/utils/actions.py b/common/utils/actions.py index 27a8299d..e87b1768 100644 --- a/common/utils/actions.py +++ b/common/utils/actions.py @@ -1,21 +1,25 @@ from typing import List, Optional, Dict, Any -from common.lib9c.fungible_asset import FungibleAsset +from common.lib9c.models.fungible_asset_value import FungibleAssetValue -def create_unload_my_garages_action_plain_value(id: str, fav_data: List, avatar_addr: str, item_data: List, memo: Optional[str]) -> Dict[str, Any]: +def create_unload_my_garages_action_plain_value(_id: str, fav_data: List, avatar_addr: str, item_data: List, + memo: Optional[str]) -> Dict[str, Any]: if avatar_addr.startswith("0x"): avatar_addr = avatar_addr[2:] return { 'type_id': 'unload_from_my_garages', 'values': { - 'id': bytes.fromhex(id), + 'id': bytes.fromhex(_id), "l": [ bytes.fromhex(avatar_addr), [ [ bytes.fromhex(x['balanceAddr'][2:] if x["balanceAddr"].startswith("0x") else x["balanceAddr"]), - FungibleAsset.to_fungible_asset(x['value']['currencyTicker'], int(x['value']['value']), int(x['value']['decimalPlaces'])) + FungibleAssetValue.from_raw_data( + x["value"]["currencyTicker"], x["value"]["decimalPlaces"], x["value"].get("minters", None), + amount=x["value"]["value"] + ).plain_value ] for x in fav_data ], diff --git a/iap/api/purchase.py b/iap/api/purchase.py index 635085ac..f60130da 100644 --- a/iap/api/purchase.py +++ b/iap/api/purchase.py @@ -3,13 +3,13 @@ import os from datetime import datetime from typing import List, Dict, Optional, Annotated -from uuid import UUID +from uuid import UUID, uuid4 import boto3 import requests from fastapi import APIRouter, Depends, Query from googleapiclient.errors import HttpError -from sqlalchemy import select +from sqlalchemy import select, or_ from sqlalchemy.orm import joinedload from starlette.responses import JSONResponse @@ -23,10 +23,10 @@ from iap.dependencies import session from iap.exceptions import ReceiptNotFoundException from iap.main import logger -from iap.schemas.receipt import ReceiptSchema, ReceiptDetailSchema +from iap.schemas.receipt import ReceiptSchema, ReceiptDetailSchema, FreeReceiptSchema from iap.utils import create_season_pass_jwt, get_purchase_count -from iap.validator.common import get_order_data from iap.validator.apple import validate_apple +from iap.validator.common import get_order_data from iap.validator.google import validate_google router = APIRouter( @@ -307,6 +307,119 @@ def request_product(receipt_data: ReceiptSchema, sess=Depends(session)): return receipt +@router.post("/free", response_model=ReceiptDetailSchema) +def free_product(receipt_data: FreeReceiptSchema, sess=Depends(session)): + """ + # Purchase Free Product + --- + + **Purchase free product and unload product from IAP garage to buyer.** + + ### Request Body + - `store` :: int : Store type in IntEnum. Please see `StoreType` Enum. + - `agentAddress` :: str : 9c agent address of buyer. + - `avatarAddress` :: str : 9c avatar address to get items. + - `sku` :: str : Purchased product SKU + """ + if not receipt_data.planetId: + raise ReceiptNotFoundException("", "") + + product = sess.scalar( + select(Product) + .options(joinedload(Product.fav_list)).options(joinedload(Product.fungible_item_list)) + .where( + Product.active.is_(True), + or_(Product.google_sku == receipt_data.sku, Product.apple_sku == receipt_data.sku) + ) + ) + order_id = f"FREE-{uuid4()}" + receipt = Receipt( + store=receipt_data.store, + data={"SKU": receipt_data.sku, "OrderId": order_id}, + agent_addr=receipt_data.agentAddress.lower(), + avatar_addr=receipt_data.avatarAddress.lower(), + order_id=order_id, + purchased_at=datetime.utcnow(), + product_id=product.id if product is not None else None, + planet_id=receipt_data.planetId.value, + ) + sess.add(receipt) + sess.commit() + sess.refresh(receipt) + + # Validation + if not product: + receipt.status = ReceiptStatus.INVALID + receipt.msg = f"Product {receipt_data.product_id} not exists or inactive" + raise_error(sess, receipt, ValueError(f"Product {receipt_data.product_id} not found or inactive")) + + if not product.is_free: + receipt.status = ReceiptStatus.INVALID + receipt.msg = "This product it not for free" + raise_error(sess, receipt, ValueError(f"Requested product {product.id}::{product.name} is not for free")) + + if ((product.open_timestamp and product.open_timestamp > datetime.now()) or + (product.close_timestamp and product.close_timestamp < datetime.now())): + receipt.status = ReceiptStatus.TIME_LIMIT + raise_error(sess, receipt, ValueError(f"Not in product opening time")) + + # Purchase Limit + if (product.daily_limit and + get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), + agent_addr=receipt.agent_addr.lower(), daily_limit=True) > product.daily_limit): + receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED + raise_error(sess, receipt, ValueError("Daily purchase limit exceeded.")) + elif (product.weekly_limit and + get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), + agent_addr=receipt.agent_addr.lower(), weekly_limit=True) > product.weekly_limit): + receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED + raise_error(sess, receipt, ValueError("Weekly purchase limit exceeded.")) + elif (product.account_limit and + get_purchase_count(sess, product.id, planet_id=PlanetID(receipt.planet_id), + agent_addr=receipt.agent_addr.lower()) > product.account_limit): + receipt.status = ReceiptStatus.PURCHASE_LIMIT_EXCEED + raise_error(sess, receipt, ValueError("Account purchase limit exceeded.")) + + # Required level + if product.required_level: + gql_url = None + if receipt_data.planetId in (PlanetID.ODIN, PlanetID.ODIN_INTERNAL): + gql_url = os.environ.get("ODIN_GQL_URL") + elif receipt_data.planetId in (PlanetID.HEIMDALL, PlanetID.HEIMDALL_INTERNAL): + gql_url = os.environ.get("HEIMDALL_GQL_URL") + + query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt_data.avatarAddress}") {{ level}} }} }}""" + try: + resp = requests.post(gql_url, json={"query": query}, timeout=1) + avatar_level = resp.json()["data"]["stateQuery"]["avatar"]["level"] + except: + # Whether request is failed or no fitted data found + avatar_level = 0 + + if avatar_level < product.required_level: + receipt.status = ReceiptStatus.REQUIRED_LEVEL + raise_error(sess, receipt, + ValueError(f"Avatar level {avatar_level} does not met required level {product.required_level}")) + + receipt.status = ReceiptStatus.VALID + sess.add(receipt) + sess.commit() + sess.refresh(receipt) + + msg = { + "agent_addr": receipt_data.agentAddress.lower(), + "avatar_addr": receipt_data.avatarAddress.lower(), + "product_id": product.id, + "uuid": str(receipt.uuid), + "planet_id": receipt_data.planetId.decode('utf-8'), + } + + resp = sqs.send_message(QueueUrl=SQS_URL, MessageBody=json.dumps(msg)) + logger.debug(f"message [{resp['MessageId']}] sent to SQS.") + + return receipt + + @router.get("/status", response_model=Dict[UUID, Optional[ReceiptDetailSchema]]) def purchase_status(uuid: Annotated[List[UUID], Query()] = ..., sess=Depends(session)): """ diff --git a/iap/iap_cdk_stack.py b/iap/iap_cdk_stack.py index cab8814a..6acb2b2c 100644 --- a/iap/iap_cdk_stack.py +++ b/iap/iap_cdk_stack.py @@ -98,6 +98,8 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: "APPLE_KEY_ID": config.apple_key_id, "APPLE_ISSUER_ID": config.apple_issuer_id, "HEADLESS": config.headless, + "ODIN_GQL_URL": config.odin_gql_url, + "HEIMDALL_GQL_URL": config.heimdall_gql_url, "CDN_HOST": config.cdn_host, "PLANET_URL": config.planet_url, "BRIDGE_DATA": config.bridge_data, diff --git a/iap/schemas/product.py b/iap/schemas/product.py index f02c3ac0..c2202218 100644 --- a/iap/schemas/product.py +++ b/iap/schemas/product.py @@ -2,20 +2,22 @@ from pydantic import BaseModel as BaseSchema, model_validator -from common.enums import Currency, ProductRarity, ProductAssetUISize +from common.enums import ProductRarity, ProductAssetUISize class SimpleProductSchema(BaseSchema): name: str order: int - google_sku: str - apple_sku: str + google_sku: str = "" + apple_sku: str = "" + is_free: bool # product_type: ProductType daily_limit: Optional[int] = None weekly_limit: Optional[int] = None account_limit: Optional[int] = None active: bool buyable: bool = True + required_level: Optional[int] = None class Config: from_attributes = True diff --git a/iap/schemas/receipt.py b/iap/schemas/receipt.py index 78b79ef5..68353406 100644 --- a/iap/schemas/receipt.py +++ b/iap/schemas/receipt.py @@ -58,6 +58,26 @@ def json_data(self) -> dict: return data +@dataclass +class FreeReceiptSchema: + sku: str + agentAddress: str + avatarAddress: str + store: Store + planetId: Union[str, PlanetID] + + def __post_init__(self): + # Reformat address to starts with `0x` + if self.agentAddress: + self.agentAddress = format_addr(self.agentAddress) + if self.avatarAddress: + self.avatarAddress = format_addr(self.avatarAddress) + + # Parse planet + if isinstance(self.planetId, str): + self.planetId = PlanetID(bytes(self.planetId, 'utf-8')) + + @dataclass class ReceiptSchema: data: Union[str, Dict, object] diff --git a/tests/lib9c/models/test_address.py b/tests/lib9c/models/test_address.py new file mode 100644 index 00000000..dd247ed8 --- /dev/null +++ b/tests/lib9c/models/test_address.py @@ -0,0 +1,32 @@ +import pytest + +from common.lib9c.models.address import Address + + +@pytest.mark.parametrize("addr", + ["0xa5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27D", + "0xb3cbca0e64aeb4b5b861047fe1db5a1bec1c241f", + "a5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27D", + "b3cbca0e64aeb4b5b861047fe1db5a1bec1c241f", + ]) +def test_address(addr): + address = Address(addr) + assert len(address.raw) == 20 + if addr.startswith("0x"): + assert address.raw == bytes.fromhex(addr[2:]) + assert address.long_format == addr.lower() + else: + assert address.raw == bytes.fromhex(addr) + assert address.short_format == addr.lower() + + +@pytest.mark.parametrize("addr", + [ + "0xa5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27X", # Invalid character + "a5f7e0bd63AD2749D66380f36Eb33Fe0ba50A27X", # Invalid character + "0xa5f7e0bd63AD2749D66380f36Eb33Fe0ba50A2", # Length + "a5f7e0bd63AD2749D66380f36Eb33Fe0ba50A2", # Length + ]) +def test_address_error(addr): + with pytest.raises(ValueError) as e: + Address(addr) diff --git a/tests/lib9c/models/test_currency.py b/tests/lib9c/models/test_currency.py new file mode 100644 index 00000000..d2630d62 --- /dev/null +++ b/tests/lib9c/models/test_currency.py @@ -0,0 +1,58 @@ +import pytest + +from common.lib9c.models.address import Address +from common.lib9c.models.currency import Currency + +TEST_DATASET = [ + ("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False, + b'du13:decimalPlaces1:\x02u7:minterslu40:47d082a115c63e7b58b1532d20e631538eafaddeeu6:tickeru3:NCGe'), + ("CRYSTAL", 18, None, False, b'du13:decimalPlaces1:\x12u7:mintersnu6:tickeru7:CRYSTALe'), + ("GARAGE", 18, None, True, b'du13:decimalPlaces1:\x12u7:mintersnu6:tickeru6:GARAGEu20:totalSupplyTrackablete'), + ("OTHER", 0, None, False, b'du13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERe'), + ("OTHER", 0, [], False, b'du13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERe'), + ("OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0"], False, + b'du13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0eu6:tickeru5:OTHERe'), + ("OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0", "0x3C32731b77C5D99D186572E5ce5d6AA93A8853dC"], False, + b'du13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0u40:3c32731b77c5d99d186572e5ce5d6aa93a8853dceu6:tickeru5:OTHERe'), +] + + +@pytest.mark.parametrize("test_data", TEST_DATASET) +def test_currency(test_data): + ticker, decimal_places, minters, total_supply_trackable, _ = test_data + currency = Currency(ticker, decimal_places, minters, total_supply_trackable) + assert currency.ticker == ticker + assert currency.decimal_places == decimal_places + assert currency.minters == ([Address(x) for x in minters] if minters else None) + if total_supply_trackable: + assert currency.total_supply_trackable is total_supply_trackable + + +def test_well_known_currency(): + test_ncg = Currency.NCG() + expected_ncg = Currency("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False) + assert test_ncg == expected_ncg + + test_crystal = Currency.CRYSTAL() + expected_crystal = Currency("CRYSTAL", 18, None, False) + assert test_crystal == expected_crystal + + +@pytest.mark.parametrize("test_data", TEST_DATASET) +def test_plain_value(test_data): + ticker, decimal_places, minters, total_supply_trackable, _ = test_data + currency = Currency(ticker, decimal_places, minters, total_supply_trackable) + plain_value = currency.plain_value + assert plain_value["ticker"] == ticker + assert plain_value["decimalPlaces"] == chr(decimal_places).encode() + assert plain_value["minters"] == ( + [x[2:].lower() if x.startswith("0x") else x.lower() for x in minters] if minters else None) + if total_supply_trackable: + assert plain_value["totalSupplyTrackable"] == total_supply_trackable + + +@pytest.mark.parametrize("test_data", TEST_DATASET) +def test_serialized_plain_value(test_data): + ticker, decimal_places, minters, total_supply_trackable, serialized = test_data + currency = Currency(ticker, decimal_places, minters, total_supply_trackable) + assert currency.serialized_plain_value == serialized diff --git a/tests/lib9c/models/test_fungible_asset_value.py b/tests/lib9c/models/test_fungible_asset_value.py new file mode 100644 index 00000000..56619a9d --- /dev/null +++ b/tests/lib9c/models/test_fungible_asset_value.py @@ -0,0 +1,70 @@ +import pytest + +from common.lib9c.models.currency import Currency +from common.lib9c.models.fungible_asset_value import FungibleAssetValue + +TEST_DATASET = [ + ("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False, 0, + b'ldu13:decimalPlaces1:\x02u7:minterslu40:47d082a115c63e7b58b1532d20e631538eafaddeeu6:tickeru3:NCGei0ee'), + ("CRYSTAL", 18, None, False, 0, b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru7:CRYSTALei0ee'), + ("GARAGE", 18, None, True, 0, + b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru6:GARAGEu20:totalSupplyTrackabletei0ee'), + ("OTHER", 0, None, False, 0, b'ldu13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERei0ee'), + ( + "OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0", "0x3C32731b77C5D99D186572E5ce5d6AA93A8853dC"], False, + 0, + b'ldu13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0u40:3c32731b77c5d99d186572e5ce5d6aa93a8853dceu6:tickeru5:OTHERei0ee' + ), + ("NCG", 2, ["47d082a115c63e7b58b1532d20e631538eafadde"], False, 1, + b'ldu13:decimalPlaces1:\x02u7:minterslu40:47d082a115c63e7b58b1532d20e631538eafaddeeu6:tickeru3:NCGei100ee'), + ("CRYSTAL", 18, None, False, 1, b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru7:CRYSTALei1000000000000000000ee'), + ("GARAGE", 18, None, True, 1, + b'ldu13:decimalPlaces1:\x12u7:mintersnu6:tickeru6:GARAGEu20:totalSupplyTrackabletei1000000000000000000ee'), + ("OTHER", 0, None, False, 1, b'ldu13:decimalPlaces1:\x00u7:mintersnu6:tickeru5:OTHERei1ee'), + ( + "OTHER", 0, ["0x896cB1A849d8818BF8e1fcf4166DafD67E27Dce0", "0x3C32731b77C5D99D186572E5ce5d6AA93A8853dC"], False, + 1, + b'ldu13:decimalPlaces1:\x00u7:minterslu40:896cb1a849d8818bf8e1fcf4166dafd67e27dce0u40:3c32731b77c5d99d186572e5ce5d6aa93a8853dceu6:tickeru5:OTHERei1ee' + ), +] + + +@pytest.mark.parametrize("test_data", TEST_DATASET) +def test_fav(test_data): + ticker, decimal_places, minters, total_supply_trackable, amount, _ = test_data + currency = Currency(ticker, decimal_places, minters, total_supply_trackable) + fav = FungibleAssetValue(currency, amount) + assert fav.currency == currency + assert fav.amount == amount + + +@pytest.mark.parametrize("test_data", TEST_DATASET) +def test_fav_from_data(test_data): + ticker, decimal_places, minters, total_supply_trackable, amount, _ = test_data + fav = FungibleAssetValue.from_raw_data(ticker, decimal_places, minters, total_supply_trackable, amount) + expected_currency = Currency(ticker, decimal_places, minters, total_supply_trackable) + assert fav.currency == expected_currency + assert fav.amount == amount + + +@pytest.mark.parametrize("test_data", TEST_DATASET) +def test_plain_value(test_data): + ticker, decimal_places, minters, total_supply_trackable, amount, _ = test_data + fav = FungibleAssetValue.from_raw_data(ticker, decimal_places, minters, total_supply_trackable, amount) + plain_value = fav.plain_value + assert plain_value[0]["ticker"] == ticker + assert plain_value[0]["decimalPlaces"] == chr(decimal_places).encode() + assert plain_value[0]["minters"] == ([x[2:].lower() if x.startswith("0x") else x.lower() for x in minters] + if minters else None) + if total_supply_trackable: + assert plain_value[0]["totalSupplyTrackable"] is True + else: + assert "totalSupplyTrackable" not in plain_value[0] + assert plain_value[1] == amount * max(1, 10 ** decimal_places) + + +@pytest.mark.parametrize("test_data", TEST_DATASET) +def test_serialized_plain_value(test_data): + ticker, decimal_places, minters, total_supply_trackable, amount, expected = test_data + fav = FungibleAssetValue.from_raw_data(ticker, decimal_places, minters, total_supply_trackable, amount) + assert fav.serialized_plain_value == expected diff --git a/tests/lib9c/test_currency.py b/tests/lib9c/test_currency.py deleted file mode 100644 index 72a192e3..00000000 --- a/tests/lib9c/test_currency.py +++ /dev/null @@ -1,30 +0,0 @@ -import bencodex -from common.lib9c.currency import Currency - - -def test_crystal(): - crystal = Currency.to_currency("crystal") - assert crystal["decimalPlaces"] == b'\x12' - assert crystal["minters"] == None - assert crystal["ticker"] == "CRYSTAL" - -def test_garage(): - garage = Currency.to_currency("garage") - assert garage["decimalPlaces"] == b'\x12' - assert garage["minters"] == None - assert garage["ticker"] == "GARAGE" - assert garage["totalSupplyTrackable"] == True - -def test_other(): - other = Currency.to_currency("other") - assert other["decimalPlaces"] == b'\x00' - assert other["minters"] == None - assert other["ticker"] == "OTHER" - -def test_serialize(): - crystal = Currency.to_currency("crystal") - assert Currency.serialize(crystal) == bencodex.dumps({'decimalPlaces': b'\x12', 'minters': None, 'ticker': 'CRYSTAL'}) - garage = Currency.to_currency("garage") - assert Currency.serialize(garage) == bencodex.dumps({'decimalPlaces': b'\x12', 'minters': None, 'ticker': 'GARAGE', 'totalSupplyTrackable': True}) - other = Currency.to_currency("other") - assert Currency.serialize(other) == bencodex.dumps({'decimalPlaces': b'\x00', 'minters': None, 'ticker': 'OTHER'}) diff --git a/tests/lib9c/test_fungible_asset.py b/tests/lib9c/test_fungible_asset.py deleted file mode 100644 index 2536966d..00000000 --- a/tests/lib9c/test_fungible_asset.py +++ /dev/null @@ -1,13 +0,0 @@ -from common.lib9c.fungible_asset import FungibleAsset - - -def test_to_fungible_asset(): - assert FungibleAsset.to_fungible_asset("CRYSTAL", 100, 18) == [{"decimalPlaces": b'\x12', "minters": None, "ticker": "CRYSTAL"}, 100 * 10**18] - assert FungibleAsset.to_fungible_asset("GARAGE", 1, 18) == [{"decimalPlaces": b'\x12', "minters": None, "ticker": "GARAGE", "totalSupplyTrackable": True}, 1 * 10**18] - assert FungibleAsset.to_fungible_asset("OTHER", 999, 0) == [{"decimalPlaces": b'\x00', "minters": None, "ticker": "OTHER"}, 999] - - -def test_serialize(): - assert FungibleAsset.serialize(FungibleAsset.to_fungible_asset("CRYSTAL", 100, 18)).hex() == "6c35323a647531333a646563696d616c506c61636573313a1275373a6d696e746572736e75363a7469636b657275373a4352595354414c65693130303030303030303030303030303030303030306565" - assert FungibleAsset.serialize(FungibleAsset.to_fungible_asset("GARAGE", 1, 18)).hex() == "6c37363a647531333a646563696d616c506c61636573313a1275373a6d696e746572736e75363a7469636b657275363a4741524147457532303a746f74616c537570706c79547261636b61626c65746569313030303030303030303030303030303030306565" - assert FungibleAsset.serialize(FungibleAsset.to_fungible_asset("OTHER", 999, 0)).hex() == "6c35303a647531333a646563696d616c506c61636573313a0075373a6d696e746572736e75363a7469636b657275353a4f5448455265693939396565" diff --git a/tests/utils/test_actions.py b/tests/utils/test_actions.py index 62f843c0..31378fa1 100644 --- a/tests/utils/test_actions.py +++ b/tests/utils/test_actions.py @@ -25,9 +25,9 @@ def test_create_unload_my_garages_action_plain_value(): "count": 200 } ] - id = "6e747fecdc33374a81fdc42b99d0d4f3" + _id = "6e747fecdc33374a81fdc42b99d0d4f3" memo = '["0x9eaac29af78f88f8dbb5fad976c683e92f25fdb3", "0x25b4ce744b7e0150ef9999b6eff5010b6d4a164a", "{\\"iap\\": {\\"g_sku\\": \\"g_pkg_launching1\\", \\"a_sku\\": \\"a_pkg_launching1\\"}}"]' - plain_value = create_unload_my_garages_action_plain_value(id, fav_data, avatar_addr, item_data, memo) + plain_value = create_unload_my_garages_action_plain_value(_id, fav_data, avatar_addr, item_data, memo) expected = {'type_id': 'unload_from_my_garages', 'values': {'id': b'nt\x7f\xec\xdc37J\x81\xfd\xc4+\x99\xd0\xd4\xf3', 'l': [b'A\xae\xfeL\xdd\xfbW\xc9\xdf\xfdI\x0e\x17\xe5qp\\Y=\xdc', [[b'\x1c*\xe9s\x80\xcf\xb4\xf72\x04\x9eEOm\x9a%\xd4\x96|o', [{'decimalPlaces': b'\x12', 'minters': None, 'ticker': 'CRYSTAL'}, 1500000000000000000000000]]], [[b'9\x91\xe0M\xd8\x08\xdc\x0b\xc2K!\xf5\xad\xb7\xbf\x19\x971/\x87\x00\xda\xf13K\xf3I6\xe8\xa0\x81:', 8000], [b'\xf8\xfa\xf9,\x9c\r\x0e\x8e\x06iCa\xea\x87\xbf\xc8\xb2\x9a\x8a\xe8\xde\x93\x04K\x98G\nWcn\xd0\xe0', 200]], '["0x9eaac29af78f88f8dbb5fad976c683e92f25fdb3", "0x25b4ce744b7e0150ef9999b6eff5010b6d4a164a", "{\\"iap\\": {\\"g_sku\\": \\"g_pkg_launching1\\", \\"a_sku\\": \\"a_pkg_launching1\\"}}"]']}} expected_hex = "6475373a747970655f69647532323a756e6c6f61645f66726f6d5f6d795f6761726167657375363a76616c7565736475323a696431363a6e747fecdc33374a81fdc42b99d0d4f375313a6c6c32303a41aefe4cddfb57c9dffd490e17e571705c593ddc6c6c32303a1c2ae97380cfb4f732049e454f6d9a25d4967c6f6c647531333a646563696d616c506c61636573313a1275373a6d696e746572736e75363a7469636b657275373a4352595354414c656931353030303030303030303030303030303030303030303030656565656c6c33323a3991e04dd808dc0bc24b21f5adb7bf1997312f8700daf1334bf34936e8a0813a693830303065656c33323af8faf92c9c0d0e8e06694361ea87bfc8b29a8ae8de93044b98470a57636ed0e069323030656565753137333a5b22307839656161633239616637386638386638646262356661643937366336383365393266323566646233222c2022307832356234636537343462376530313530656639393939623665666635303130623664346131363461222c20227b5c226961705c223a207b5c22675f736b755c223a205c22675f706b675f6c61756e6368696e67315c222c205c22615f736b755c223a205c22615f706b675f6c61756e6368696e67315c227d7d225d656565" diff --git a/worker/worker/handler.py b/worker/worker/handler.py index 6f968576..e5c753c0 100644 --- a/worker/worker/handler.py +++ b/worker/worker/handler.py @@ -117,7 +117,7 @@ def process(sess: Session, message: SQSMessageRecord, nonce: int = None) -> Tupl } for x in product.fungible_item_list] unload_from_garage = create_unload_my_garages_action_plain_value( - id=uuid.uuid1().hex, + _id=uuid.uuid1().hex, fav_data=fav_data, avatar_addr=avatar_address, item_data=item_data, @@ -129,6 +129,7 @@ def process(sess: Session, message: SQSMessageRecord, nonce: int = None) -> Tupl public_key=account.pubkey.hex(), address=account.address, nonce=nonce, plain_value=unload_from_garage, timestamp=datetime.datetime.utcnow() + datetime.timedelta(days=1) ) + signature = account.sign_tx(unsigned_tx) signed_tx = append_signature_to_unsigned_tx(unsigned_tx, signature) return gql.stage(signed_tx), nonce, signed_tx diff --git a/worker/worker/status_monitor.py b/worker/worker/status_monitor.py index f1b419d6..fde4f504 100644 --- a/worker/worker/status_monitor.py +++ b/worker/worker/status_monitor.py @@ -12,6 +12,7 @@ from common.utils.aws import fetch_secrets from common.utils.receipt import PlanetID +STAGE = os.environ.get("STAGE") DB_URI = os.environ.get("DB_URI") db_password = fetch_secrets(os.environ.get("REGION_NAME"), os.environ.get("SECRET_ARN"))["password"] DB_URI = DB_URI.replace("[DB_PASSWORD]", db_password) @@ -28,6 +29,17 @@ "48e50ecd6d1aa2689fd349c1f0611e6cc1e9c4c74ec4de9d4637ec7b78617308": "Golden Meat (800202)", } +VIEW_ORDER = ( + "CRYSTAL", + FUNGIBLE_DICT["3991e04dd808dc0bc24b21f5adb7bf1997312f8700daf1334bf34936e8a0813a"], # Hourglass + FUNGIBLE_DICT["00dfffe23964af9b284d121dae476571b7836b8d9e2e5f510d92a840fecc64fe"], # AP Potion + "RUNE_GOLDENLEAF", + FUNGIBLE_DICT["f8faf92c9c0d0e8e06694361ea87bfc8b29a8ae8de93044b98470a57636ed0e0"], # Golden Dust + FUNGIBLE_DICT["48e50ecd6d1aa2689fd349c1f0611e6cc1e9c4c74ec4de9d4637ec7b78617308"], # Golden Meat + FUNGIBLE_DICT["1a755098a2bc0659a063107df62e2ff9b3cdaba34d96b79519f504b996f53820"], # Silver Dust + "SOULSTONE_1001", "SOULSTONE_1002", "SOULSTONE_1003", "SOULSTONE_1004", +) + engine = create_engine(DB_URI) @@ -148,13 +160,21 @@ def check_garage(): fav_data = data["garageBalances"] item_data = data["fungibleItemGarages"] - msg = [] + result_dict = {} + for fav in fav_data: - msg.append(create_block(f"{fav['currency']['ticker']} : {int(fav['quantity'].split('.')[0]):,}")) + result_dict[fav["currency"]["ticker"]] = fav["quantity"].split(".")[0] for item in item_data: - msg.append(create_block(f"{FUNGIBLE_DICT[item['fungibleItemId']]} : {item['count']:,}")) + result_dict[FUNGIBLE_DICT[item["fungibleItemId"]]] = item["count"] + + msg = [] + for key in VIEW_ORDER: + result = result_dict.pop(key) + msg.append(create_block(f"{key} : {int(result):,}")) + for key, result in result_dict.items(): + msg.append(create_block(f"{key} : {int(result):,}")) - send_message(IAP_GARAGE_WEBHOOK_URL, "[NineChronicles.IAP] Daily Garage Report", msg) + send_message(IAP_GARAGE_WEBHOOK_URL, f"[NineChronicles.IAP] Daily Garage Report - {STAGE}", msg) def handle(event, context):