From 9859121c4080abfdcb35dd69cbbe96773b859857 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 8 Mar 2024 00:35:01 +0300 Subject: [PATCH] dev@locazia: make deploy blockchain route --- app/api/middleware.py | 19 ++++++ app/api/routes/_blockchain.py | 92 +++++++++++++++++++++++++++--- app/api/routes/node_storage.py | 13 ++++- app/core/_utils/resolve_content.py | 2 +- app/core/content/content_id.py | 4 ++ app/core/content/utils.py | 60 ++++++++++++++++++- docs/indexation.md | 41 +++++++++++++ 7 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 docs/indexation.md diff --git a/app/api/middleware.py b/app/api/middleware.py index fd920f5..c103c27 100644 --- a/app/api/middleware.py +++ b/app/api/middleware.py @@ -2,6 +2,8 @@ from app.core.models.user import User from app.core.models.keys import KnownKey from app.core.storage import Session from app.core.logger import make_log +from app.core._crypto.signer import Signer +from app.core._secrets import hot_seed from sanic import response as sanic_response from base58 import b58encode, b58decode @@ -54,11 +56,28 @@ async def try_authorization(request): request.ctx.user_key = known_key +async def try_service_authorization(request): + signature = request.headers.get('X-Service-Signature') + if not signature: + return + + message_hash_b58 = request.headers.get('X-Message-Hash') + if not message_hash_b58: + return + + message_hash = b58decode(message_hash_b58) + signer = Signer(hot_seed) + if signer.verify(message_hash, signature): + request.ctx.verified_hash = message_hash + + async def attach_user_to_request(request): + request.ctx.verified_hash = None request.ctx.user = None request.ctx.user_key = None request.ctx.db_session = Session() await try_authorization(request) + await try_service_authorization(request) async def close_request_handler(request, response): diff --git a/app/api/routes/_blockchain.py b/app/api/routes/_blockchain.py index 6daf489..7063d4a 100644 --- a/app/api/routes/_blockchain.py +++ b/app/api/routes/_blockchain.py @@ -1,6 +1,15 @@ from app.core._blockchain.ton.connect import TonConnect, unpack_wallet_info +from app.core.content.utils import create_metadata_for_item +from app.core.models.node_storage import StoredContent from sanic import response from datetime import datetime, timedelta +from app.core._blockchain.ton.platform import platform +from app.core._config import PROJECT_HOST +from base64 import b64encode, b64decode +from base58 import b58decode, b58encode +from app.core._utils.resolve_content import resolve_content +from tonsdk.boc import begin_cell, Cell, begin_dict +from tonsdk.utils import Address def valid_royalty_params(royalty_params): @@ -37,17 +46,84 @@ async def s_api_v1_blockchain_send_new_content_message(request): await ton_connect.restore_connection() assert ton_connect.connected, "No connected wallet" + encrypted_content_cid, err = resolve_content(request.json['content']) + assert not err, f"Invalid content CID" + encrypted_content = request.ctx.db_session.query(StoredContent).filter( + StoredContent.hash == encrypted_content_cid.content_hash_b58 + ).first() + assert encrypted_content, "No content locally found" + assert encrypted_content.type == "local/content_bin", "Invalid content type" + image_content_cid = resolve_content(request.json['image']) + assert not err, f"Invalid image CID" + image_content = request.ctx.db_session.query(StoredContent).filter( + StoredContent.hash == image_content_cid.content_hash_b58 + ).first() + assert image_content, "No image locally found" - # await ton_connect._sdk_client.send_transaction({ - # 'valid_until': int(datetime.now().timestamp()), - # 'messages': [ - # { - # 'address': - # } - # ] - # }) + metadata_content = await create_metadata_for_item( + request.ctx.db_session, + title=request.json['title'], + cover_url=request.json['image'], + authors=request.json['authors'] + ) + royalties_dict = begin_dict(8) + i = 0 + for royalty_param in request.json['royaltyParams']: + royalties_dict.store_ref( + i, begin_cell() + .store_address(Address(royalty_param['address'])) + .store_uint(royalty_param['value'], 16) + .end_cell() + ) + i += 1 + + await ton_connect._sdk_client.send_transaction({ + 'valid_until': int(datetime.now().timestamp() + 120), + 'messages': [ + { + 'address': platform.address.to_string(1, 1, 1), + 'amount': str(int(0.15 * 10 ** 9)), + 'payload': b64encode( + begin_cell() + .store_uint(0x5491d08c, 32) + .store_uint(int(encrypted_content_cid.content_hash.hex()[2:], 16), 256) + .store_ref( + begin_cell() + .store_ref( + begin_cell() + .store_coins(int(request.json['price'])) + .store_coins(int(1000000 * 10 ** 9)) + .store_coins(int(5000000 * 10 ** 9)) + .end_cell() + ) + .store_maybe_ref(royalties_dict.end_dict()) + .store_uint(0, 1) + .end_cell() + ) + .store_ref( + begin_cell() + .store_ref( + begin_cell() + .store_uint(1, 8) + .store_bytes(f"{PROJECT_HOST}/api/v1/storage/{metadata_content.hash}".encode()) + .end_cell() + ) + .store_ref( + begin_cell() + .store_ref(begin_cell().store_bytes(f"{encrypted_content_cid.serialize_v1()}".encode()).end_cell()) + .store_ref(begin_cell().store_bytes(f"{image_content_cid.serialize_v1()}".encode()).end_cell()) + .store_ref(begin_cell().store_bytes(f"{metadata_content.serialize_v1()}".encode()).end_cell()) + .end_cell() + ) + .end_cell() + ) + .end_cell().to_boc(False) + ).decode() + } + ] + }) return response.json({"message": "Transaction requested"}) diff --git a/app/api/routes/node_storage.py b/app/api/routes/node_storage.py index fd8d159..84689dc 100644 --- a/app/api/routes/node_storage.py +++ b/app/api/routes/node_storage.py @@ -12,8 +12,7 @@ import aiofiles async def s_api_v1_storage_post(request): - assert request.ctx.user, "No authorized" - if not request.files and not request.json: + if not request.files: return response.json({"error": "No file provided"}, status=400) file_param = list(request.files.values())[0][0] if request.files else None @@ -34,7 +33,8 @@ async def s_api_v1_storage_post(request): file_meta["extension_encoding"] = file_encoding try: - file_hash = b58encode(hashlib.sha256(file_content).digest()).decode() + file_hash_bin = hashlib.sha256(file_content).digest() + file_hash = b58encode(file_hash_bin).decode() stored_content = request.ctx.db_session.query(StoredContent).filter(StoredContent.hash == file_hash).first() if stored_content: stored_cid = stored_content.cid.serialize_v1() @@ -44,6 +44,13 @@ async def s_api_v1_storage_post(request): "content_url": f"dmy://storage?cid={stored_cid}", }) + if request.ctx.user: + pass + elif request.ctx.verified_hash: + assert request.ctx.verified_hash == file_hash_bin, "Invalid service request hash" + else: + return response.json({"error": "Unauthorized"}, status=401) + new_content = StoredContent( type="local/content_bin", user_id=request.ctx.user.id, diff --git a/app/core/_utils/resolve_content.py b/app/core/_utils/resolve_content.py index 7e703eb..be48459 100644 --- a/app/core/_utils/resolve_content.py +++ b/app/core/_utils/resolve_content.py @@ -1,7 +1,7 @@ from app.core.content.content_id import ContentId -def resolve_content(content_id): # -> [content, error] +def resolve_content(content_id) -> ContentId: # -> [content, error] try: return ContentId.deserialize(content_id), None except BaseException as e: diff --git a/app/core/content/content_id.py b/app/core/content/content_id.py index 3744cac..f0dc0dc 100644 --- a/app/core/content/content_id.py +++ b/app/core/content/content_id.py @@ -17,6 +17,10 @@ class ContentId: self.accept_type = accept_type + @property + def content_hash_b58(self) -> str: + return b58encode(self.content_hash).decode() + def serialize_v1(self) -> str: at_bin = string_to_bytes_fixed_size(self.accept_type, 15) assert len(self.content_hash) == 32, "Invalid hash length" diff --git a/app/core/content/utils.py b/app/core/content/utils.py index e64b37b..a68149a 100644 --- a/app/core/content/utils.py +++ b/app/core/content/utils.py @@ -1,6 +1,64 @@ from app.core.models.node_storage import StoredContent +from app.core._crypto.signer import Signer +from app.core._secrets import hot_seed +from hashlib import sha256 +from base58 import b58encode, b58decode + +from app.core._config import PROJECT_HOST +from httpx import AsyncClient +import json -async def create_metadata_for_item(**kwargs): +async def create_metadata_for_item( + db_session, + title: str = None, + cover_url: str = None, + authors: list = None +) -> StoredContent: + assert title, "No title provided" + assert cover_url, "No cover_url provided" + assert len(title) > 3, "Title too short" + title = title[:100].strip() + item_metadata = { + 'name': title, + 'description': '@MY Content Ownership Proof NFT', + 'attributes': [ + # { + # 'trait_type': 'Artist', + # 'value': 'Unknown' + # }, + ] + } + if cover_url: + item_metadata['image'] = cover_url + item_metadata['authors'] = [ + ''.join([_a_ch for _a_ch in _a if len(_a_ch.encode()) == 1]) for _a in (authors or [])[:500] + ] + + signer = Signer(hot_seed) + + # Upload file + metadata_bin = json.dumps(item_metadata).encode() + metadata_hash = sha256(metadata_bin).digest() + metadata_hash_b58 = b58encode(metadata_hash).decode() + + async with AsyncClient() as client: + response = await client.post( + f"{PROJECT_HOST}/api/v1/storage", + files={"file": ('metadata.json', metadata_bin, 'application/json')}, + headers={ + 'X-Service-Signature': signer.sign(metadata_bin), + 'X-Message-Hash': metadata_hash_b58, + } + ) + assert response.status_code == 200 + response_json = response.json() + metadata_sha256 = response_json['content_sha256'] + + metadata_content = db_session.query(StoredContent).filter(StoredContent.hash == metadata_sha256).first() + if metadata_content: + return metadata_content + + raise Exception("Metadata not created") diff --git a/docs/indexation.md b/docs/indexation.md new file mode 100644 index 0000000..9a97bb4 --- /dev/null +++ b/docs/indexation.md @@ -0,0 +1,41 @@ +## Indexation + +### Stored content types + +- `local/content_bin` – binary content stored only locally (or indexer no found it on chain) +- `onchain/content` - content stored onchain +- `onchain/content_unknown` - content stored onchain, but we don't have a private key to decrypt it + +Content item may have multiple types, for example, `local/content_bin` and `onchain/content`. + +But `content cover`, `content metadata` and `decrypted content` always stored locally. + +### Content Ownership Proof NFT Values Cell Deserialization + +```text +values:^[ + content_hash:uint256 + metadata:^[ + offchain?:int1 = always 1 + https://my-public-node-1.projscale.dev/*:bytes + ] + content:^[ + content_cid:^Cell = b58encoded CID + cover_cid:^Cell = b58encoded CID + metadata_cid:^Cell = b58encoded CID + ] +] +``` + +### Available content statuses + +- `UPLOAD_TO_BTFS` – content is stored locally, upload all content parts to BTFS. This status means that payment is received yet. + + +### Upload content flow + +1. User uploads content to server (/api/v1/storage) +2. User uploads content cover to server (/api/v1/storage) +3. User send /api/v1/blockchain.sendNewContentMessage to server and accept the transaction in wallet +4. Indexer receives the transaction and indexes the content. And send telegram notification to user. +