Implement RECEIPT_CLAIM transaction type
Some checks failed
Blockchain Synchronization Verification / sync-verification (push) Successful in 4s
Documentation Validation / validate-docs (push) Successful in 12s
Documentation Validation / validate-policies-strict (push) Successful in 3s
Integration Tests / test-service-integration (push) Failing after 12s
Multi-Node Blockchain Health Monitoring / health-check (push) Successful in 3s
P2P Network Verification / p2p-verification (push) Successful in 2s
Python Tests / test-python (push) Successful in 10s
Security Scanning / security-scan (push) Successful in 31s

- Add status fields to Receipt model (status, claimed_at, claimed_by)
- Add RECEIPT_CLAIM handling to state_transition.py with validation and reward minting
- Add type field to Transaction model for reliable transaction type storage
- Update router to use TransactionRequest model to preserve type field
- Update poa.py to extract type from mempool transaction content and store only original payload
- Add RECEIPT_CLAIM to GasType enum with gas schedule
This commit is contained in:
aitbc
2026-04-22 13:35:31 +02:00
parent a6a840a930
commit f36fd45d28
40 changed files with 1194 additions and 349 deletions

View File

@@ -229,15 +229,11 @@ class PoAProposer:
# Apply state transition through validated transaction
state_transition = get_state_transition()
tx_data = {
"from": sender,
"to": recipient,
"value": value,
"fee": fee,
"nonce": sender_account.nonce
}
# Use original tx_data from mempool to preserve type and payload
tx_data_for_transition = tx.content.copy()
tx_data_for_transition["nonce"] = sender_account.nonce
success, error_msg = state_transition.apply_transaction(
session, self._config.chain_id, tx_data, tx.tx_hash
session, self._config.chain_id, tx_data_for_transition, tx.tx_hash
)
if not success:
@@ -257,18 +253,30 @@ class PoAProposer:
continue
# Create transaction record
# Extract type from normalized tx_data (which should have the type field)
tx_type = tx.content.get("type", "TRANSFER")
self._logger.info(f"[PROPOSE] Transaction {tx.tx_hash} content type: {tx_type}, full content: {tx.content}")
if tx_type:
tx_type = tx_type.upper()
else:
tx_type = "TRANSFER"
# Store only the original payload, not the full normalized data
original_payload = tx.content.get("payload", {})
transaction = Transaction(
chain_id=self._config.chain_id,
tx_hash=tx.tx_hash,
sender=sender,
recipient=recipient,
payload=tx_data,
payload=original_payload,
value=value,
fee=fee,
nonce=sender_account.nonce - 1,
timestamp=timestamp,
block_height=next_height,
status="confirmed"
status="confirmed",
type=tx_type
)
session.add(transaction)
processed_txs.append(tx)

View File

@@ -95,6 +95,7 @@ class Transaction(SQLModel, table=True):
nonce: int = Field(default=0)
value: int = Field(default=0)
fee: int = Field(default=0)
type: str = Field(default="TRANSFER", index=True)
status: str = Field(default="pending")
timestamp: Optional[str] = Field(default=None)
tx_metadata: Optional[str] = Field(default=None)
@@ -140,6 +141,9 @@ class Receipt(SQLModel, table=True):
)
minted_amount: Optional[int] = None
recorded_at: datetime = Field(default_factory=datetime.utcnow, index=True)
status: str = Field(default="pending", index=True) # pending, claimed, invalid
claimed_at: Optional[datetime] = None
claimed_by: Optional[str] = None
# Relationship
block: Optional["Block"] = Relationship(

View File

@@ -87,12 +87,28 @@ def _normalize_transaction_data(tx_data: Dict[str, Any], chain_id: str) -> Dict[
if nonce < 0:
raise ValueError("transaction.nonce must be non-negative")
payload = tx_data.get("payload", "0x")
payload = tx_data.get("payload", {})
if payload is None:
payload = "0x"
payload = {}
tx_type = tx_data.get("type", "TRANSFER")
if tx_type:
tx_type = tx_type.upper()
# Ensure payload is a dict
if isinstance(payload, str):
try:
import json
payload = json.loads(payload)
except:
payload = {}
if not isinstance(payload, dict):
payload = {}
return {
"chain_id": chain_id,
"type": tx_type,
"from": sender.strip(),
"to": recipient.strip(),
"amount": amount,
@@ -174,7 +190,7 @@ def _serialize_receipt(receipt: Receipt) -> Dict[str, Any]:
class TransactionRequest(BaseModel):
type: str = Field(description="Transaction type, e.g. TRANSFER, RECEIPT_CLAIM, GPU_MARKETPLACE, EXCHANGE, MESSAGE")
sender: str
sender: str = Field(alias="from") # Accept both "sender" and "from"
nonce: int
fee: int = Field(ge=0)
payload: Dict[str, Any]
@@ -271,15 +287,33 @@ async def get_block(height: int, chain_id: str = None) -> Dict[str, Any]:
@router.post("/transaction", summary="Submit transaction")
async def submit_transaction(tx_data: dict) -> Dict[str, Any]:
async def submit_transaction(tx_data: TransactionRequest) -> Dict[str, Any]:
"""Submit a new transaction to the mempool"""
from ..mempool import get_mempool
try:
mempool = get_mempool()
chain_id = tx_data.get("chain_id") or get_chain_id(None)
chain_id = get_chain_id(None)
tx_data_dict = _normalize_transaction_data(tx_data, chain_id)
# Convert TransactionRequest to dict for normalization
# _normalize_transaction_data expects "from", not "sender"
tx_data_dict = {
"from": tx_data.sender,
"to": tx_data.payload.get("to", tx_data.sender), # Get to from payload or default to sender
"amount": tx_data.payload.get("amount", 0), # Get amount from payload
"fee": tx_data.fee,
"nonce": tx_data.nonce,
"payload": tx_data.payload,
"type": tx_data.type,
"signature": tx_data.sig
}
_logger.info(f"[ROUTER] Before normalization: type={tx_data.type}, full dict keys={list(tx_data_dict.keys())}")
tx_data_dict = _normalize_transaction_data(tx_data_dict, chain_id)
_logger.info(f"[ROUTER] After normalization: type={tx_data_dict.get('type')}, full dict keys={list(tx_data_dict.keys())}")
_validate_transaction_admission(tx_data_dict, mempool)
tx_hash = mempool.add(tx_data_dict, chain_id=chain_id)

View File

@@ -10,8 +10,9 @@ from __future__ import annotations
from typing import Dict, List, Optional, Tuple
from sqlmodel import Session, select
from datetime import datetime
from ..models import Account, Transaction
from ..models import Account, Transaction, Receipt
from ..logger import get_logger
@@ -68,8 +69,27 @@ class StateTransition:
if tx_nonce != expected_nonce:
return False, f"Invalid nonce for {sender_addr}: expected {expected_nonce}, got {tx_nonce}"
# Get transaction type
tx_type = tx_data.get("type", "TRANSFER").upper()
# Get transaction type - check Transaction model first, then tx_data
tx_record = session.exec(
select(Transaction).where(
Transaction.chain_id == chain_id,
Transaction.tx_hash == tx_hash
)
).first()
if tx_record and tx_record.type:
tx_type = tx_record.type.upper()
else:
tx_type = tx_data.get("type", "TRANSFER")
if not tx_type or tx_type == "TRANSFER":
# Check if type is in payload
payload = tx_data.get("payload", {})
if isinstance(payload, dict):
tx_type = payload.get("type", "TRANSFER")
if tx_type:
tx_type = tx_type.upper()
else:
tx_type = "TRANSFER"
# Validate balance
value = tx_data.get("value", 0)
@@ -88,14 +108,42 @@ class StateTransition:
if sender_account.balance < total_cost:
return False, f"Insufficient balance for {sender_addr}: {sender_account.balance} < {total_cost}"
# Get recipient account (not required for MESSAGE)
# Get recipient account (not required for MESSAGE or RECEIPT_CLAIM)
recipient_addr = tx_data.get("to")
if tx_type != "MESSAGE":
if tx_type not in {"MESSAGE", "RECEIPT_CLAIM"}:
recipient_account = session.get(Account, (chain_id, recipient_addr))
if not recipient_account:
return False, f"Recipient account not found: {recipient_addr}"
# For RECEIPT_CLAIM transactions, validate receipt exists
if tx_type == "RECEIPT_CLAIM":
receipt_id = tx_data.get("payload", {}).get("receipt_id")
if not receipt_id:
return False, "RECEIPT_CLAIM transactions must include receipt_id in payload"
receipt = session.exec(
select(Receipt).where(
Receipt.chain_id == chain_id,
Receipt.receipt_id == receipt_id
)
).first()
if not receipt:
return False, f"Receipt not found: {receipt_id}"
if receipt.status != "pending":
return False, f"Receipt already claimed or invalid: {receipt.status}"
# Basic signature validation (full validation requires aitbc_sdk)
# Check that miner_signature exists and is non-empty
if not receipt.miner_signature or not isinstance(receipt.miner_signature, dict):
return False, f"Receipt {receipt_id} has invalid miner signature"
# Check that coordinator_attestations exists and is non-empty
if not receipt.coordinator_attestations or not isinstance(receipt.coordinator_attestations, list):
return False, f"Receipt {receipt_id} has invalid coordinator attestations"
return True, "Transaction validated successfully"
def apply_transaction(
@@ -128,8 +176,27 @@ class StateTransition:
sender_account = session.get(Account, (chain_id, sender_addr))
# Get transaction type
tx_type = tx_data.get("type", "TRANSFER").upper()
# Get transaction type - check Transaction model first, then tx_data
tx_record = session.exec(
select(Transaction).where(
Transaction.chain_id == chain_id,
Transaction.tx_hash == tx_hash
)
).first()
if tx_record and tx_record.type:
tx_type = tx_record.type.upper()
else:
tx_type = tx_data.get("type", "TRANSFER")
if not tx_type or tx_type == "TRANSFER":
# Check if type is in payload
payload = tx_data.get("payload", {})
if isinstance(payload, dict):
tx_type = payload.get("type", "TRANSFER")
if tx_type:
tx_type = tx_type.upper()
else:
tx_type = "TRANSFER"
# Apply balance changes
value = tx_data.get("value", 0)
@@ -149,6 +216,30 @@ class StateTransition:
if tx_type != "MESSAGE":
recipient_account.balance += value
# For RECEIPT_CLAIM transactions, mint reward and update receipt status
if tx_type == "RECEIPT_CLAIM":
receipt_id = tx_data.get("payload", {}).get("receipt_id")
receipt = session.exec(
select(Receipt).where(
Receipt.chain_id == chain_id,
Receipt.receipt_id == receipt_id
)
).first()
if receipt and receipt.minted_amount:
# Mint reward to claimant (sender)
sender_account.balance += receipt.minted_amount
# Update receipt status
receipt.status = "claimed"
receipt.claimed_at = datetime.utcnow()
receipt.claimed_by = sender_addr
logger.info(
f"Claimed receipt {receipt_id}: "
f"minted_amount={receipt.minted_amount}, claimed_by={sender_addr}"
)
# Mark transaction as processed
self._processed_tx_hashes.add(tx_hash)
self._processed_nonces[sender_addr] = sender_account.nonce