diff --git a/plugin/python/contract/contract.py b/plugin/python/contract/contract.py index 9f94658b96..25dbf82e8a 100644 --- a/plugin/python/contract/contract.py +++ b/plugin/python/contract/contract.py @@ -25,6 +25,10 @@ PluginEndRequest, PluginEndResponse, MessageSend, + MessageReward, + MessageFaucet, + Faucet, + Reward, PluginKeyRead, PluginStateReadRequest, PluginStateWriteRequest, @@ -54,11 +58,14 @@ "name": "python_plugin_contract", "id": 1, "version": 1, - "supported_transactions": ["send"], + "supported_transactions": ["send", "reward", "faucet"], "transaction_type_urls": [ "type.googleapis.com/types.MessageSend", + "type.googleapis.com/types.MessageReward", + "type.googleapis.com/types.MessageFaucet", ], "event_type_urls": [], + "custom_state_prefixes": [FAUCET_PREFIX, REWARD_PREFIX], # Include google/protobuf/any.proto first as it's a dependency of event.proto and tx.proto "file_descriptor_protos": [ any_pb2.DESCRIPTOR.serialized_pb, @@ -74,6 +81,8 @@ ACCOUNT_PREFIX = b"\x01" POOL_PREFIX = b"\x02" PARAMS_PREFIX = b"\x07" +FAUCET_PREFIX = b"\x64" # 100 - outside core reserved range 1-15 +REWARD_PREFIX = b"\x65" # 101 - outside core reserved range 1-15 # Key generation functions (from keys.py) @@ -115,6 +124,16 @@ def key_for_fee_pool(chain_id: int) -> bytes: return join_len_prefix(POOL_PREFIX, format_uint64(chain_id)) +def key_for_faucet(address: bytes) -> bytes: + """Generate state database key for a faucet record.""" + return join_len_prefix(FAUCET_PREFIX, address) + + +def key_for_reward(address: bytes) -> bytes: + """Generate state database key for a reward record.""" + return join_len_prefix(REWARD_PREFIX, address) + + # Proto marshal/unmarshal utilities def marshal(message: Any) -> bytes: @@ -203,6 +222,14 @@ async def check_tx(self, request: PluginCheckRequest) -> PluginCheckResponse: msg = MessageSend() msg.ParseFromString(request.tx.msg.value) return self._check_message_send(msg) + elif type_url.endswith("/types.MessageReward"): + msg = MessageReward() + msg.ParseFromString(request.tx.msg.value) + return self._check_message_reward(msg) + elif type_url.endswith("/types.MessageFaucet"): + msg = MessageFaucet() + msg.ParseFromString(request.tx.msg.value) + return self._check_message_faucet(msg) else: raise err_invalid_message_cast() @@ -228,6 +255,14 @@ async def deliver_tx(self, request: PluginDeliverRequest) -> PluginDeliverRespon msg = MessageSend() msg.ParseFromString(request.tx.msg.value) return await self._deliver_message_send(msg, request.tx.fee) + elif type_url.endswith("/types.MessageReward"): + msg = MessageReward() + msg.ParseFromString(request.tx.msg.value) + return await self._deliver_message_reward(msg, request.tx.fee) + elif type_url.endswith("/types.MessageFaucet"): + msg = MessageFaucet() + msg.ParseFromString(request.tx.msg.value) + return await self._deliver_message_faucet(msg) else: raise err_invalid_message_cast() @@ -268,6 +303,30 @@ def _check_message_send(self, msg: MessageSend) -> PluginCheckResponse: response.authorized_signers.append(msg.from_address) return response + def _check_message_faucet(self, msg: MessageFaucet) -> PluginCheckResponse: + if len(msg.signer_address) != 20: + raise err_invalid_address() + if len(msg.recipient_address) != 20: + raise err_invalid_address() + if msg.amount == 0: + raise err_invalid_amount() + response = PluginCheckResponse() + response.recipient = msg.recipient_address + response.authorized_signers.append(msg.signer_address) + return response + + def _check_message_reward(self, msg: MessageReward) -> PluginCheckResponse: + if len(msg.admin_address) != 20: + raise err_invalid_address() + if len(msg.recipient_address) != 20: + raise err_invalid_address() + if msg.amount == 0: + raise err_invalid_amount() + response = PluginCheckResponse() + response.recipient = msg.recipient_address + response.authorized_signers.append(msg.admin_address) + return response + async def _deliver_message_send(self, msg: MessageSend, fee: int) -> PluginDeliverResponse: """DeliverMessageSend handles a 'send' message.""" if not self.plugin or not self.config: @@ -373,3 +432,160 @@ async def _deliver_message_send(self, msg: MessageSend, fee: int) -> PluginDeliv if write_resp.HasField("error"): result.error.CopyFrom(write_resp.error) return result + + async def _deliver_message_faucet(self, msg: MessageFaucet) -> PluginDeliverResponse: + if not self.plugin or not self.config: + raise PluginError(1, "plugin", "plugin or config not initialized") + + recipient_query_id = random.randint(0, 2 ** 53) + faucet_query_id = random.randint(0, 2 ** 53) + + recipient_key = key_for_account(msg.recipient_address) + faucet_key = key_for_faucet(msg.recipient_address) + + response = await self.plugin.state_read( + self, + PluginStateReadRequest( + keys=[ + PluginKeyRead(query_id=recipient_query_id, key=recipient_key), + PluginKeyRead(query_id=faucet_query_id, key=faucet_key), + ] + ), + ) + + if response.HasField("error"): + result = PluginDeliverResponse() + result.error.CopyFrom(response.error) + return result + + recipient_bytes = None + faucet_bytes = None + + for resp in response.results: + if resp.query_id == recipient_query_id: + recipient_bytes = resp.entries[0].value if resp.entries else None + elif resp.query_id == faucet_query_id: + faucet_bytes = resp.entries[0].value if resp.entries else None + + recipient_account = unmarshal(Account, recipient_bytes) if recipient_bytes else Account() + faucet_record = unmarshal(Faucet, faucet_bytes) if faucet_bytes else Faucet() + + recipient_account.amount += msg.amount + faucet_record.recipient_address = msg.recipient_address + faucet_record.total_amount += msg.amount + faucet_record.count += 1 + + recipient_bytes_new = marshal(recipient_account) + faucet_bytes_new = marshal(faucet_record) + + write_resp = await self.plugin.state_write( + self, + PluginStateWriteRequest( + sets=[ + PluginSetOp(key=recipient_key, value=recipient_bytes_new), + PluginSetOp(key=faucet_key, value=faucet_bytes_new), + ], + ), + ) + + result = PluginDeliverResponse() + if write_resp.HasField("error"): + result.error.CopyFrom(write_resp.error) + return result + + async def _deliver_message_reward(self, msg: MessageReward, fee: int) -> PluginDeliverResponse: + if not self.plugin or not self.config: + raise PluginError(1, "plugin", "plugin or config not initialized") + + admin_query_id = random.randint(0, 2 ** 53) + recipient_query_id = random.randint(0, 2 ** 53) + fee_query_id = random.randint(0, 2 ** 53) + reward_query_id = random.randint(0, 2 ** 53) + + admin_key = key_for_account(msg.admin_address) + recipient_key = key_for_account(msg.recipient_address) + fee_pool_key = key_for_fee_pool(self.config.chain_id) + reward_key = key_for_reward(msg.recipient_address) + + response = await self.plugin.state_read( + self, + PluginStateReadRequest( + keys=[ + PluginKeyRead(query_id=fee_query_id, key=fee_pool_key), + PluginKeyRead(query_id=admin_query_id, key=admin_key), + PluginKeyRead(query_id=recipient_query_id, key=recipient_key), + PluginKeyRead(query_id=reward_query_id, key=reward_key), + ] + ), + ) + + if response.HasField("error"): + result = PluginDeliverResponse() + result.error.CopyFrom(response.error) + return result + + admin_bytes = None + recipient_bytes = None + fee_pool_bytes = None + reward_bytes = None + + for resp in response.results: + if resp.query_id == admin_query_id: + admin_bytes = resp.entries[0].value if resp.entries else None + elif resp.query_id == recipient_query_id: + recipient_bytes = resp.entries[0].value if resp.entries else None + elif resp.query_id == fee_query_id: + fee_pool_bytes = resp.entries[0].value if resp.entries else None + elif resp.query_id == reward_query_id: + reward_bytes = resp.entries[0].value if resp.entries else None + + admin_account = unmarshal(Account, admin_bytes) if admin_bytes else Account() + recipient_account = unmarshal(Account, recipient_bytes) if recipient_bytes else Account() + fee_pool = unmarshal(Pool, fee_pool_bytes) if fee_pool_bytes else Pool() + reward_record = unmarshal(Reward, reward_bytes) if reward_bytes else Reward() + + if admin_account.amount < fee: + raise err_insufficient_funds() + + admin_account.amount -= fee + recipient_account.amount += msg.amount + fee_pool.amount += fee + reward_record.recipient_address = msg.recipient_address + reward_record.last_admin_address = msg.admin_address + reward_record.total_amount += msg.amount + reward_record.count += 1 + + admin_bytes_new = marshal(admin_account) + recipient_bytes_new = marshal(recipient_account) + fee_pool_bytes_new = marshal(fee_pool) + reward_bytes_new = marshal(reward_record) + + if admin_account.amount == 0: + write_resp = await self.plugin.state_write( + self, + PluginStateWriteRequest( + sets=[ + PluginSetOp(key=fee_pool_key, value=fee_pool_bytes_new), + PluginSetOp(key=recipient_key, value=recipient_bytes_new), + PluginSetOp(key=reward_key, value=reward_bytes_new), + ], + deletes=[PluginDeleteOp(key=admin_key)], + ), + ) + else: + write_resp = await self.plugin.state_write( + self, + PluginStateWriteRequest( + sets=[ + PluginSetOp(key=fee_pool_key, value=fee_pool_bytes_new), + PluginSetOp(key=admin_key, value=admin_bytes_new), + PluginSetOp(key=recipient_key, value=recipient_bytes_new), + PluginSetOp(key=reward_key, value=reward_bytes_new), + ], + ), + ) + + result = PluginDeliverResponse() + if write_resp.HasField("error"): + result.error.CopyFrom(write_resp.error) + return result diff --git a/plugin/python/contract/proto/__init__.py b/plugin/python/contract/proto/__init__.py index 0d19af09bf..1e2ed2db86 100644 --- a/plugin/python/contract/proto/__init__.py +++ b/plugin/python/contract/proto/__init__.py @@ -8,7 +8,7 @@ # Import generated protobuf classes from .account_pb2 import Account, Pool # type: ignore[attr-defined] from .event_pb2 import Event, EventCustom # type: ignore[attr-defined] -from .tx_pb2 import Transaction, MessageSend, FeeParams, Signature # type: ignore[attr-defined] +from .tx_pb2 import Transaction, MessageSend, MessageReward, MessageFaucet, Faucet, Reward, FeeParams, Signature # type: ignore[attr-defined] # Import plugin proto classes from .plugin_pb2 import ( # type: ignore[attr-defined] @@ -55,6 +55,10 @@ # Transaction types "Transaction", "MessageSend", + "MessageReward", + "MessageFaucet", + "Faucet", + "Reward", "FeeParams", "Signature", # Plugin communication types diff --git a/plugin/python/contract/proto/tx.proto b/plugin/python/contract/proto/tx.proto index 8c064be9d6..d921c3bf9b 100644 --- a/plugin/python/contract/proto/tx.proto +++ b/plugin/python/contract/proto/tx.proto @@ -54,3 +54,32 @@ message Signature { // signature: the bytes of the signature output from a private key which may be verified with the message and public bytes signature = 2; } + +// MessageReward mints tokens to a recipient +message MessageReward { + bytes admin_address = 1; + bytes recipient_address = 2; + uint64 amount = 3; +} + +// MessageFaucet is a test-only transaction that mints tokens to any address +message MessageFaucet { + bytes signer_address = 1; + bytes recipient_address = 2; + uint64 amount = 3; +} + +// Faucet is a state record tracking cumulative faucet mints to a recipient +message Faucet { + bytes recipient_address = 1; + uint64 total_amount = 2; + uint64 count = 3; +} + +// Reward is a state record tracking cumulative reward mints to a recipient +message Reward { + bytes recipient_address = 1; + bytes last_admin_address = 2; + uint64 total_amount = 3; + uint64 count = 4; +} diff --git a/plugin/python/contract/proto/tx_pb2.py b/plugin/python/contract/proto/tx_pb2.py index 7737bfefd6..2f4627a779 100644 --- a/plugin/python/contract/proto/tx_pb2.py +++ b/plugin/python/contract/proto/tx_pb2.py @@ -15,7 +15,7 @@ from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x08tx.proto\x12\x05types\x1a\x19google/protobuf/any.proto\"\xd2\x01\n\x0bTransaction\x12\x14\n\x0cmessage_type\x18\x01 \x01(\t\x12!\n\x03msg\x18\x02 \x01(\x0b\x32\x14.google.protobuf.Any\x12#\n\tsignature\x18\x03 \x01(\x0b\x32\x10.types.Signature\x12\x16\n\x0e\x63reated_height\x18\x04 \x01(\x04\x12\x0c\n\x04time\x18\x05 \x01(\x04\x12\x0b\n\x03\x66\x65\x65\x18\x06 \x01(\x04\x12\x0c\n\x04memo\x18\x07 \x01(\t\x12\x12\n\nnetwork_id\x18\x08 \x01(\x04\x12\x10\n\x08\x63hain_id\x18\t \x01(\x04\"G\n\x0bMessageSend\x12\x14\n\x0c\x66rom_address\x18\x01 \x01(\x0c\x12\x12\n\nto_address\x18\x02 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x03 \x01(\x04\"\x1d\n\tFeeParams\x12\x10\n\x08send_fee\x18\x01 \x01(\x04\"2\n\tSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42.Z,github.com/canopy-network/go-plugin/contractb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x08tx.proto\x12\x05types\x1a\x19google/protobuf/any.proto\"\xd2\x01\n\x0bTransaction\x12\x14\n\x0cmessage_type\x18\x01 \x01(\t\x12!\n\x03msg\x18\x02 \x01(\x0b\x32\x14.google.protobuf.Any\x12#\n\tsignature\x18\x03 \x01(\x0b\x32\x10.types.Signature\x12\x16\n\x0e\x63reated_height\x18\x04 \x01(\x04\x12\x0c\n\x04time\x18\x05 \x01(\x04\x12\x0b\n\x03\x66\x65\x65\x18\x06 \x01(\x04\x12\x0c\n\x04memo\x18\x07 \x01(\t\x12\x12\n\nnetwork_id\x18\x08 \x01(\x04\x12\x10\n\x08\x63hain_id\x18\t \x01(\x04\"G\n\x0bMessageSend\x12\x14\n\x0c\x66rom_address\x18\x01 \x01(\x0c\x12\x12\n\nto_address\x18\x02 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x03 \x01(\x04\"\x1d\n\tFeeParams\x12\x10\n\x08send_fee\x18\x01 \x01(\x04\"2\n\tSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"Q\n\rMessageReward\x12\x15\n\radmin_address\x18\x01 \x01(\x0c\x12\x19\n\x11recipient_address\x18\x02 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x03 \x01(\x04\"R\n\rMessageFaucet\x12\x16\n\x0esigner_address\x18\x01 \x01(\x0c\x12\x19\n\x11recipient_address\x18\x02 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x03 \x01(\x04\"H\n\x06\x46\x61ucet\x12\x19\n\x11recipient_address\x18\x01 \x01(\x0c\x12\x14\n\x0ctotal_amount\x18\x02 \x01(\x04\x12\r\n\x05\x63ount\x18\x03 \x01(\x04\"d\n\x06Reward\x12\x19\n\x11recipient_address\x18\x01 \x01(\x0c\x12\x1a\n\x12last_admin_address\x18\x02 \x01(\x0c\x12\x14\n\x0ctotal_amount\x18\x03 \x01(\x04\x12\r\n\x05\x63ount\x18\x04 \x01(\x04\x42.Z,github.com/canopy-network/go-plugin/contractb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -31,4 +31,12 @@ _globals['_FEEPARAMS']._serialized_end=361 _globals['_SIGNATURE']._serialized_start=363 _globals['_SIGNATURE']._serialized_end=413 + _globals['_MESSAGEREWARD']._serialized_start=415 + _globals['_MESSAGEREWARD']._serialized_end=496 + _globals['_MESSAGEFAUCET']._serialized_start=498 + _globals['_MESSAGEFAUCET']._serialized_end=580 + _globals['_FAUCET']._serialized_start=582 + _globals['_FAUCET']._serialized_end=654 + _globals['_REWARD']._serialized_start=656 + _globals['_REWARD']._serialized_end=756 # @@protoc_insertion_point(module_scope) diff --git a/plugin/python/contract/rpc.py b/plugin/python/contract/rpc.py index 64e9e667c8..032e76adc5 100644 --- a/plugin/python/contract/rpc.py +++ b/plugin/python/contract/rpc.py @@ -49,13 +49,18 @@ # ... # decode resp, unmarshal(MyRecord, value), and self._write_json(...) """ +import asyncio import json import logging import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Optional +from urllib.parse import urlparse, parse_qs from .plugin import Plugin, PLUGIN_BUILD +from .proto import PluginStateReadRequest, PluginKeyRead, PluginRangeRead +from .proto import Faucet, Reward +from .contract import key_for_faucet, key_for_reward, FAUCET_PREFIX, REWARD_PREFIX, unmarshal logger = logging.getLogger(__name__) @@ -71,8 +76,15 @@ class PluginRPCHandler(BaseHTTPRequestHandler): plugin: Optional[Plugin] = None def do_GET(self) -> None: # noqa: N802 (http.server API) - # No routes are registered by default. Builders add route dispatch here. - self._write_json_error(404, "not found") + parsed = urlparse(self.path) + query = parse_qs(parsed.query) + + if parsed.path == "/v1/query/faucets": + self._handle_query_faucets(query) + elif parsed.path == "/v1/query/rewards": + self._handle_query_rewards(query) + else: + self._write_json_error(404, "not found") def _write_json(self, body: dict, status: int = 200) -> None: """Write a JSON success response.""" @@ -92,6 +104,122 @@ def _write_json_error(self, status: int, message: str) -> None: self.end_headers() self.wfile.write(data) + def _handle_query_faucets(self, query: dict) -> None: + plugin = self.plugin + if not plugin: + self._write_json_error(500, "plugin not initialized") + return + + height = int(query.get("height", [0])[0]) + addresses = query.get("address") + + if addresses: + addr_bytes = bytes.fromhex(addresses[0]) + key = key_for_faucet(addr_bytes) + request = PluginStateReadRequest(keys=[PluginKeyRead(query_id=0, key=key)]) + else: + request = PluginStateReadRequest( + ranges=[PluginRangeRead(query_id=0, prefix=FAUCET_PREFIX, limit=0, reverse=False)] + ) + + coro = plugin.query_state(height, request) + future = asyncio.run_coroutine_threadsafe(coro, plugin._loop) + + try: + resp = future.result(timeout=15.0) + except Exception as e: + self._write_json_error(500, str(e)) + return + + if addresses: + faucet = None + if resp.results and resp.results[0].entries: + faucet_bytes = resp.results[0].entries[0].value + if faucet_bytes: + faucet_pb = Faucet() + faucet_pb.ParseFromString(faucet_bytes) + faucet = { + "recipientAddress": faucet_pb.recipient_address.hex(), + "totalAmount": faucet_pb.total_amount, + "count": faucet_pb.count, + } + self._write_json({ + "faucet": faucet or {"recipientAddress": addresses[0], "totalAmount": 0, "count": 0}, + "height": height, + }) + else: + faucets = [] + for result in resp.results: + for entry in result.entries: + if entry.value: + faucet_pb = Faucet() + faucet_pb.ParseFromString(entry.value) + faucets.append({ + "recipientAddress": faucet_pb.recipient_address.hex(), + "totalAmount": faucet_pb.total_amount, + "count": faucet_pb.count, + }) + self._write_json({"faucets": faucets, "count": len(faucets), "height": height}) + + def _handle_query_rewards(self, query: dict) -> None: + plugin = self.plugin + if not plugin: + self._write_json_error(500, "plugin not initialized") + return + + height = int(query.get("height", [0])[0]) + addresses = query.get("address") + + if addresses: + addr_bytes = bytes.fromhex(addresses[0]) + key = key_for_reward(addr_bytes) + request = PluginStateReadRequest(keys=[PluginKeyRead(query_id=0, key=key)]) + else: + request = PluginStateReadRequest( + ranges=[PluginRangeRead(query_id=0, prefix=REWARD_PREFIX, limit=0, reverse=False)] + ) + + coro = plugin.query_state(height, request) + future = asyncio.run_coroutine_threadsafe(coro, plugin._loop) + + try: + resp = future.result(timeout=15.0) + except Exception as e: + self._write_json_error(500, str(e)) + return + + if addresses: + reward = None + if resp.results and resp.results[0].entries: + reward_bytes = resp.results[0].entries[0].value + if reward_bytes: + reward_pb = Reward() + reward_pb.ParseFromString(reward_bytes) + reward = { + "recipientAddress": reward_pb.recipient_address.hex(), + "lastAdminAddress": reward_pb.last_admin_address.hex(), + "totalAmount": reward_pb.total_amount, + "count": reward_pb.count, + } + self._write_json({ + "reward": reward or {"recipientAddress": addresses[0], "lastAdminAddress": "", "totalAmount": 0, "count": 0}, + "height": height, + }) + else: + rewards = [] + for result in resp.results: + for entry in result.entries: + if entry.value: + reward_pb = Reward() + reward_pb.ParseFromString(entry.value) + rewards.append({ + "recipientAddress": reward_pb.recipient_address.hex(), + "lastAdminAddress": reward_pb.last_admin_address.hex(), + "totalAmount": reward_pb.total_amount, + "count": reward_pb.count, + }) + self._write_json({"rewards": rewards, "count": len(rewards), "height": height}) + def log_message(self, format: str, *args) -> None: # noqa: A002 (http.server API) """Route default access logging through the module logger at debug level.""" logger.debug("plugin RPC: %s", format % args)