Files
B.Tech-Project-III/negot8/backend/blockchain_web3/blockchain.py
2026-04-05 00:43:23 +05:30

211 lines
8.2 KiB
Python

"""
blockchain.py — The entire Polygon Amoy integration in one file.
Silently registers agreement proofs on-chain after every resolved negotiation.
Users never interact with this module — they only see the "🔗 Verified" badge
and a PolygonScan link in their Telegram message / dashboard card.
Graceful fallback: if blockchain is down or not configured, the negotiation
still completes normally. Web3 is additive, never blocking.
"""
import hashlib
import json
import os
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware
import sys as _sys
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from contract_abi import AGREEMENT_REGISTRY_ABI
# ── Configuration ─────────────────────────────────────────────────────────────
POLYGON_RPC = os.getenv("POLYGON_RPC_URL", "https://rpc-amoy.polygon.technology/")
PRIVATE_KEY = os.getenv("POLYGON_PRIVATE_KEY", "")
CONTRACT_ADDRESS = os.getenv("AGREEMENT_CONTRACT_ADDRESS", "")
EXPLORER_BASE = "https://amoy.polygonscan.com"
CHAIN_ID = 80002
# Fallback RPCs tried in order if the primary is slow or down
_FALLBACK_RPCS = [
"https://rpc-amoy.polygon.technology/",
"https://polygon-amoy-bor-rpc.publicnode.com",
"https://polygon-amoy.drpc.org",
]
# ── Connect to Polygon Amoy ───────────────────────────────────────────────────
def _make_w3(rpc_url: str) -> Web3:
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 15}))
# Polygon uses POA consensus — inject middleware to handle extraData field
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
return w3
def _connect() -> Web3:
"""Try each RPC in order, return the first that connects."""
rpcs = [POLYGON_RPC] + [r for r in _FALLBACK_RPCS if r != POLYGON_RPC]
for rpc in rpcs:
try:
w3 = _make_w3(rpc)
if w3.is_connected():
return w3
except Exception:
continue
# Return last attempt even if not connected — errors will be caught downstream
return _make_w3(POLYGON_RPC)
w3 = _connect()
account = None
contract = None
if PRIVATE_KEY and CONTRACT_ADDRESS:
try:
account = w3.eth.account.from_key(PRIVATE_KEY)
contract = w3.eth.contract(
address=Web3.to_checksum_address(CONTRACT_ADDRESS),
abi=AGREEMENT_REGISTRY_ABI,
)
print(f"✅ Blockchain ready | Polygon Amoy | Signer: {account.address}")
except Exception as e:
print(f"⚠️ Blockchain setup failed: {e} — falling back to mock proofs.")
else:
print("⚠️ Blockchain not configured (POLYGON_PRIVATE_KEY / AGREEMENT_CONTRACT_ADDRESS missing). "
"Agreement proofs will be mocked.")
# ── Core helpers ──────────────────────────────────────────────────────────────
def hash_agreement(resolution_data: dict) -> bytes:
"""
Produce a deterministic SHA-256 digest of the resolution JSON.
Returns raw bytes (32 bytes) suitable for bytes32 in Solidity.
"""
canonical = json.dumps(resolution_data, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).digest()
def _mock_proof(negotiation_id: str, agreement_hash: bytes, error: str = "") -> dict:
"""Return a well-structured mock/fallback proof dict."""
tag = "MOCK" if not error else "FAILED"
return {
"success": not bool(error),
"mock": True,
"error": error,
"tx_hash": f"0x{tag}_{negotiation_id}_{'a' * 20}",
"block_number": 0,
"agreement_hash": "0x" + agreement_hash.hex(),
"explorer_url": EXPLORER_BASE,
"gas_used": 0,
"network": f"polygon-amoy ({'mock' if not error else 'failed — ' + error[:60]})",
}
# ── Main public function ──────────────────────────────────────────────────────
async def register_agreement_on_chain(
negotiation_id: str,
feature_type: str,
summary: str,
resolution_data: dict,
) -> dict:
"""
Register an immutable agreement proof on Polygon Amoy.
Called automatically after every resolved negotiation.
INVISIBLE to the user — they only see the PolygonScan link in their message.
Returns a dict with keys:
success, mock, tx_hash, block_number, agreement_hash,
explorer_url, gas_used, network
"""
agreement_hash = hash_agreement(resolution_data)
# ── No contract configured → return labelled mock ──────────────────────
if not contract or not account:
return _mock_proof(negotiation_id, agreement_hash)
try:
nonce = w3.eth.get_transaction_count(account.address)
gas_price = w3.eth.gas_price
tip = w3.to_wei(35, "gwei")
max_fee = gas_price + tip
tx = contract.functions.registerAgreement(
negotiation_id,
agreement_hash, # bytes32 — raw 32-byte digest
feature_type,
summary[:256], # cap at 256 chars to keep gas low
).build_transaction({
"from": account.address,
"nonce": nonce,
"gas": 300_000,
"maxFeePerGas": max_fee,
"maxPriorityFeePerGas": tip,
"chainId": CHAIN_ID,
})
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
tx_hex = tx_hash.hex()
# Polygon Amoy confirms in ~2 s; wait up to 60 s
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
result = {
"success": True,
"mock": False,
"tx_hash": tx_hex,
"block_number": receipt.blockNumber,
"agreement_hash": "0x" + agreement_hash.hex(),
"explorer_url": f"{EXPLORER_BASE}/tx/0x{tx_hex}",
"gas_used": receipt.gasUsed,
"network": "polygon-amoy",
}
print(f"✅ On-chain proof registered: {result['explorer_url']}")
return result
except Exception as e:
print(f"❌ Blockchain registration failed for {negotiation_id}: {e}")
# Negotiation still works — we just note the failure
return _mock_proof(negotiation_id, agreement_hash, error=str(e))
# ── Verification helper (used by dashboard) ───────────────────────────────────
def verify_agreement_on_chain(negotiation_id: str) -> dict:
"""
Read the stored agreement from the contract (view call — free, no gas).
Used by the dashboard to independently confirm on-chain state.
"""
if not contract:
return {"verified": False, "reason": "Contract not configured"}
try:
result = contract.functions.getAgreement(negotiation_id).call()
# result is a tuple: (agreementHash, featureType, summary, timestamp, registeredBy)
if result[3] == 0: # timestamp == 0 means not found
return {"verified": False, "reason": "Agreement not found on-chain"}
return {
"verified": True,
"agreement_hash": "0x" + result[0].hex(),
"feature_type": result[1],
"summary": result[2],
"timestamp": result[3],
"registered_by": result[4],
"explorer_url": f"{EXPLORER_BASE}/address/{CONTRACT_ADDRESS}",
}
except Exception as e:
return {"verified": False, "reason": str(e)}
def get_total_agreements() -> int:
"""Return the total number of agreements registered on-chain."""
if not contract:
return 0
try:
return contract.functions.totalAgreements().call()
except Exception:
return 0