Skip to content

Commit 4502d4e

Browse files
committed
Refactor code and enhance maintainability.
1 parent 29240ea commit 4502d4e

43 files changed

Lines changed: 13518 additions & 1508 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/cicd.yml

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,43 @@ jobs:
5858
--check "**/*.{js,jsx,ts,tsx,json,html,css,md,sol,yml,yaml}"
5959
6060
# ============================================================
61-
# Job 2 -- Backend Tests (Python / Pytest)
61+
# Job 2 -- Blockchain (Smart Contract) Compile & Test
62+
# ============================================================
63+
# Previously nothing in CI actually compiled or ran the Solidity test
64+
# suite - code_quality only checked contract *formatting* via Prettier.
65+
# A contract change that broke compilation or a test could merge with a
66+
# fully green CI run.
67+
blockchain_tests:
68+
name: Blockchain Compile & Test
69+
runs-on: ubuntu-latest
70+
needs: code_quality
71+
72+
defaults:
73+
run:
74+
working-directory: code/blockchain
75+
76+
steps:
77+
- name: Checkout repository
78+
uses: actions/checkout@v4
79+
80+
- name: Set up Node.js 20
81+
uses: actions/setup-node@v4
82+
with:
83+
node-version: "20"
84+
cache: "npm"
85+
cache-dependency-path: code/blockchain/package-lock.json
86+
87+
- name: Install dependencies
88+
run: npm ci
89+
90+
- name: Compile contracts
91+
run: npm run compile
92+
93+
- name: Run contract test suite
94+
run: npm test
95+
96+
# ============================================================
97+
# Job 3 -- Backend Tests (Python / Pytest)
6298
# ============================================================
6399
backend_tests:
64100
name: Backend Tests
@@ -107,7 +143,7 @@ jobs:
107143
retention-days: 30
108144

109145
# ============================================================
110-
# Job 3 -- Web Frontend Build
146+
# Job 4 -- Web Frontend Build
111147
# ============================================================
112148
web-frontend_test_and_build:
113149
name: Web-Frontend Test & Build
@@ -147,7 +183,7 @@ jobs:
147183
retention-days: 7
148184

149185
# ============================================================
150-
# Job 4 -- Mobile Frontend (Expo) Build & Test
186+
# Job 5 -- Mobile Frontend (Expo) Build & Test
151187
# ============================================================
152188
mobile_frontend:
153189
name: Mobile-Frontend Test & Build

code/backend/.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ ENCRYPTION_KEY=your-encryption-key-32-chars-long
5454
FIELD_ENCRYPTION_ENABLED=true
5555

5656
# Blockchain Configuration
57+
# For local development, leave ETH_RPC_URL/addresses unset and instead run
58+
# `npm run deploy:localhost` in code/blockchain (after `npm run node` there
59+
# in another terminal) - the backend automatically picks up RPC URL and
60+
# contract addresses from the deployment manifest that writes to
61+
# (BLOCKCHAIN_DEPLOYMENT_FILE below), no copy-pasting addresses required.
5762
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR_PROJECT_ID
5863
ETH_WEBSOCKET_URL=wss://mainnet.infura.io/ws/v3/YOUR_PROJECT_ID
5964
ETH_CHAIN_ID=1
@@ -63,8 +68,19 @@ BSC_RPC_URL=https://bsc-dataseed.binance.org/
6368
BSC_CHAIN_ID=56
6469
GAS_PRICE_STRATEGY=medium
6570
MAX_GAS_PRICE=100
71+
WEB3_REQUEST_TIMEOUT=5
72+
73+
# ChainFinity's own deployed contracts (see code/blockchain/contracts).
74+
# Leave unset to resolve from BLOCKCHAIN_DEPLOYMENT_FILE instead (local dev
75+
# default); set explicitly for staging/production, where they take
76+
# priority over the deployment file.
6677
GOVERNANCE_TOKEN_ADDRESS=
6778
ASSET_VAULT_ADDRESS=
79+
CROSS_CHAIN_MANAGER_ADDRESS=
80+
DEFI_PROTOCOL_ADDRESS=
81+
GOVERNANCE_ADDRESS=
82+
BLOCKCHAIN_DEPLOYMENT_FILE=../blockchain/deployments/contracts.localhost.json
83+
6884
ETHERSCAN_API_KEY=your_etherscan_api_key
6985
POLYGONSCAN_API_KEY=your_polygonscan_api_key
7086

code/backend/app/api/v1/endpoints/blockchain.py

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@
1212
from fastapi import APIRouter, Depends, HTTPException, Query, status
1313
from models.blockchain import BlockchainNetwork, ContractEvent, SmartContract
1414
from models.user import User
15-
from schemas.blockchain import ContractResponse, EventResponse, NetworkResponse
15+
from schemas.blockchain import (
16+
ContractResponse,
17+
DeployedContractResponse,
18+
DeployedContractsResponse,
19+
EventResponse,
20+
NetworkResponse,
21+
)
22+
from services.blockchain import BlockchainUnavailableError, web3_client
1623
from sqlalchemy import desc, select
1724
from sqlalchemy.ext.asyncio import AsyncSession
1825

@@ -232,19 +239,30 @@ async def verify_blockchain_address(
232239
Verify blockchain address format and validity
233240
"""
234241
try:
235-
# Basic validation
236-
is_valid = False
242+
# web3's is_address covers what a hand-rolled `len(address) == 42`
243+
# check doesn't: non-hex characters, and (via is_checksum_address)
244+
# a mixed-case address whose checksum doesn't match its digits -
245+
# both silently passed the old length-only check.
246+
is_valid = web3_client.is_valid_address(address)
237247
address_type = "unknown"
238-
239-
if network.lower() in ["ethereum", "polygon", "bsc"]:
240-
# Check if it's a valid Ethereum-style address
241-
if address.startswith("0x") and len(address) == 42:
242-
is_valid = True
243-
address_type = "EOA" # Externally Owned Account
244-
# Could check if it's a contract by querying the network
248+
checksum_address = None
249+
250+
if is_valid:
251+
checksum_address = web3_client.to_checksum_address(address)
252+
try:
253+
is_contract = await web3_client.is_contract_address(address)
254+
address_type = "contract" if is_contract else "EOA"
255+
except BlockchainUnavailableError as exc:
256+
# Format is still valid even if we can't reach the chain to
257+
# tell EOA from contract - report that distinctly from an
258+
# actually-invalid address instead of returning "unknown"
259+
# silently for both cases.
260+
logger.info(f"Could not classify address {address}: {exc}")
261+
address_type = "unknown (RPC unavailable)"
245262

246263
return {
247264
"address": address,
265+
"checksum_address": checksum_address,
248266
"network": network,
249267
"is_valid": is_valid,
250268
"address_type": address_type,
@@ -267,20 +285,33 @@ async def get_address_balance(
267285
db: AsyncSession = Depends(get_async_session),
268286
) -> Any:
269287
"""
270-
Get balance for a blockchain address
288+
Get the native-currency balance for a blockchain address on the
289+
configured RPC network (see ETH_RPC_URL). "network" is currently
290+
informational only - this backend talks to a single configured chain;
291+
per-network routing (Polygon, BSC, ...) would need a
292+
network -> Web3Client mapping in services.blockchain.
271293
"""
294+
if not web3_client.is_valid_address(address):
295+
raise HTTPException(
296+
status_code=status.HTTP_400_BAD_REQUEST,
297+
detail="Invalid blockchain address",
298+
)
272299
try:
273-
# In a real implementation, this would query the blockchain
274-
# For now, return a mock response
300+
balance_wei = await web3_client.get_balance(address)
275301
return {
276302
"address": address,
277303
"network": network,
278-
"balance": "0",
279-
"balance_usd": "0",
304+
"balance": str(balance_wei / 10**18),
305+
"balance_wei": str(balance_wei),
280306
"tokens": [],
281307
"last_updated": datetime.now(timezone.utc).isoformat(),
282308
}
283-
309+
except BlockchainUnavailableError as e:
310+
logger.warning(f"Blockchain unavailable while fetching balance: {e}")
311+
raise HTTPException(
312+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
313+
detail="Blockchain RPC endpoint is currently unavailable",
314+
)
284315
except Exception as e:
285316
logger.error(f"Error getting address balance: {e}")
286317
raise HTTPException(
@@ -295,13 +326,32 @@ async def get_gas_price(
295326
db: AsyncSession = Depends(get_async_session),
296327
) -> Any:
297328
"""
298-
Get current gas price for a network
329+
Get the current gas price from the configured RPC network. Falls back to
330+
a clearly-labeled estimate (rather than erroring the whole request) when
331+
the RPC endpoint isn't reachable, since this is typically a secondary
332+
display widget rather than something a transaction is submitted from.
299333
"""
300334
try:
301-
# Mock gas price response
335+
gas_price_wei = await web3_client.get_gas_price()
336+
gas_price_gwei = gas_price_wei / 10**9
337+
return {
338+
"network": network,
339+
"timestamp": datetime.now(timezone.utc).isoformat(),
340+
"live": True,
341+
"gas_prices": {
342+
"slow": str(round(gas_price_gwei * 0.9, 2)),
343+
"standard": str(round(gas_price_gwei, 2)),
344+
"fast": str(round(gas_price_gwei * 1.2, 2)),
345+
"rapid": str(round(gas_price_gwei * 1.5, 2)),
346+
},
347+
"unit": "gwei",
348+
}
349+
except BlockchainUnavailableError as e:
350+
logger.info(f"Gas price RPC call failed, returning estimate: {e}")
302351
return {
303352
"network": network,
304353
"timestamp": datetime.now(timezone.utc).isoformat(),
354+
"live": False,
305355
"gas_prices": {
306356
"slow": "20",
307357
"standard": "25",
@@ -310,7 +360,6 @@ async def get_gas_price(
310360
},
311361
"unit": "gwei",
312362
}
313-
314363
except Exception as e:
315364
logger.error(f"Error getting gas price: {e}")
316365
raise HTTPException(
@@ -319,6 +368,38 @@ async def get_gas_price(
319368
)
320369

321370

371+
@router.get("/deployed-contracts", response_model=DeployedContractsResponse)
372+
async def get_deployed_contracts() -> Any:
373+
"""
374+
Address book for ChainFinity's own protocol contracts (AssetVault,
375+
CrossChainManager, InstitutionalDeFiProtocol, GovernanceToken,
376+
InstitutionalGovernance) on the connected network. This is the
377+
integration point clients (web/mobile) use to know what to call - see
378+
web-frontend/src/services/api.js's blockchainAPI.getDeployedContracts.
379+
380+
`connected: false` with an empty/partial contract list means the RPC
381+
endpoint (ETH_RPC_URL) isn't reachable right now, not that the contracts
382+
don't exist - addresses resolved from BLOCKCHAIN_DEPLOYMENT_FILE or the
383+
explicit *_ADDRESS settings are still returned either way.
384+
"""
385+
contracts = web3_client.get_deployed_contracts()
386+
connected = await web3_client.is_connected()
387+
chain_id = await web3_client.get_chain_id() if connected else None
388+
389+
return DeployedContractsResponse(
390+
chain_id=chain_id,
391+
connected=connected,
392+
contracts=[
393+
DeployedContractResponse(
394+
name=name,
395+
address=contract.address,
396+
has_abi=bool(contract.abi),
397+
)
398+
for name, contract in sorted(contracts.items())
399+
],
400+
)
401+
402+
322403
# ── Frontend convenience endpoints ───────────────────────────────────────────
323404
# The web and mobile clients call these portfolio/transaction/eth-balance
324405
# routes. They return blockchain-derived views in the shapes the clients
@@ -442,18 +523,36 @@ async def get_eth_balance(
442523
db: AsyncSession = Depends(get_async_session),
443524
) -> Any:
444525
"""
445-
Return the current user's native ETH balance. Uses the user's primary
446-
wallet address when available.
526+
Return the current user's native ETH balance, read live from the
527+
configured RPC network for their linked primary_wallet_address.
447528
"""
529+
wallet = getattr(current_user, "primary_wallet_address", None)
530+
531+
if not wallet:
532+
return {
533+
"address": None,
534+
"network": "ethereum",
535+
"balance": None,
536+
"balance_wei": None,
537+
"message": "No wallet address linked to this account",
538+
"last_updated": datetime.now(timezone.utc).isoformat(),
539+
}
540+
448541
try:
449-
wallet = getattr(current_user, "primary_wallet_address", None)
542+
balance_wei = await web3_client.get_balance(wallet)
450543
return {
451544
"address": wallet,
452545
"network": "ethereum",
453-
"balance": "4.2",
454-
"balance_usd": "12600.00",
546+
"balance": str(balance_wei / 10**18),
547+
"balance_wei": str(balance_wei),
455548
"last_updated": datetime.now(timezone.utc).isoformat(),
456549
}
550+
except BlockchainUnavailableError as e:
551+
logger.warning(f"Blockchain unavailable while fetching ETH balance: {e}")
552+
raise HTTPException(
553+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
554+
detail="Blockchain RPC endpoint is currently unavailable",
555+
)
457556
except Exception as e:
458557
logger.error(f"Error getting ETH balance: {e}")
459558
raise HTTPException(

code/backend/config/settings.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,36 @@ class Settings(BaseSettings):
8383
BSC_CHAIN_ID: int = Field(default=56)
8484
GAS_PRICE_STRATEGY: str = Field(default="medium")
8585
MAX_GAS_PRICE: int = Field(default=100)
86+
# RPC request timeout, in seconds. Applies to every call the backend
87+
# makes to ETH_RPC_URL (see services/blockchain/client.py). Kept
88+
# short so a misconfigured/unreachable node degrades an API response
89+
# in ~5s instead of hanging the request.
90+
WEB3_REQUEST_TIMEOUT: int = Field(default=5)
91+
92+
# ChainFinity protocol contract addresses on ETH_RPC_URL / ETH_CHAIN_ID.
93+
# For local development these are picked up automatically from
94+
# code/blockchain/deployments/contracts.<network>.json (see
95+
# BLOCKCHAIN_DEPLOYMENT_FILE below and Web3Client._load_deployment) after
96+
# running `npm run deploy:localhost` in code/blockchain - you don't need
97+
# to copy addresses here by hand. Set these explicitly for staging/
98+
# production, where they take priority over the deployment file.
8699
GOVERNANCE_TOKEN_ADDRESS: Optional[str] = None
87100
ASSET_VAULT_ADDRESS: Optional[str] = None
101+
CROSS_CHAIN_MANAGER_ADDRESS: Optional[str] = None
102+
DEFI_PROTOCOL_ADDRESS: Optional[str] = None
103+
GOVERNANCE_ADDRESS: Optional[str] = None
104+
105+
# Path to the JSON manifest scripts/deploy.js writes (address + ABI per
106+
# contract). Relative paths are resolved against the backend package
107+
# root (see services/blockchain/client.py:_BACKEND_ROOT), not the
108+
# process's current working directory. Defaults to the localhost
109+
# deployment produced by the blockchain workspace's own dev workflow
110+
# (see code/blockchain/README and scripts/run_chainfinity.sh) so a
111+
# fresh local checkout works with zero configuration once contracts are
112+
# deployed.
113+
BLOCKCHAIN_DEPLOYMENT_FILE: str = Field(
114+
default="../blockchain/deployments/contracts.localhost.json"
115+
)
88116
ETHERSCAN_API_KEY: Optional[str] = None
89117
POLYGONSCAN_API_KEY: Optional[str] = None
90118

@@ -261,8 +289,13 @@ def __init__(self, settings: Settings) -> None:
261289
self.BSC_CHAIN_ID = settings.BSC_CHAIN_ID
262290
self.GAS_PRICE_STRATEGY = settings.GAS_PRICE_STRATEGY
263291
self.MAX_GAS_PRICE = settings.MAX_GAS_PRICE
292+
self.WEB3_REQUEST_TIMEOUT = settings.WEB3_REQUEST_TIMEOUT
264293
self.GOVERNANCE_TOKEN_ADDRESS = settings.GOVERNANCE_TOKEN_ADDRESS
265294
self.ASSET_VAULT_ADDRESS = settings.ASSET_VAULT_ADDRESS
295+
self.CROSS_CHAIN_MANAGER_ADDRESS = settings.CROSS_CHAIN_MANAGER_ADDRESS
296+
self.DEFI_PROTOCOL_ADDRESS = settings.DEFI_PROTOCOL_ADDRESS
297+
self.GOVERNANCE_ADDRESS = settings.GOVERNANCE_ADDRESS
298+
self.BLOCKCHAIN_DEPLOYMENT_FILE = settings.BLOCKCHAIN_DEPLOYMENT_FILE
266299
self.ETHERSCAN_API_KEY = settings.ETHERSCAN_API_KEY
267300
self.POLYGONSCAN_API_KEY = settings.POLYGONSCAN_API_KEY
268301

code/backend/models/base.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime, timezone
33
from typing import Any, Dict, Optional
44

5-
from sqlalchemy import JSON, UUID, Boolean, Column, DateTime, Integer, String
5+
from sqlalchemy import JSON, Boolean, Column, DateTime, Integer, String, Uuid
66
from sqlalchemy.ext.declarative import declared_attr
77
from sqlalchemy.orm import declarative_base
88

@@ -11,7 +11,7 @@
1111

1212
class BaseModel(Base):
1313
__abstract__ = True
14-
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
14+
id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
1515

1616
@declared_attr
1717
def __tablename__(cls: Any) -> str:
@@ -61,8 +61,8 @@ def restore(self) -> None:
6161

6262

6363
class AuditMixin:
64-
created_by = Column(UUID(as_uuid=True), nullable=True, index=True)
65-
updated_by = Column(UUID(as_uuid=True), nullable=True, index=True)
64+
created_by = Column(Uuid(as_uuid=True), nullable=True, index=True)
65+
updated_by = Column(Uuid(as_uuid=True), nullable=True, index=True)
6666
audit_metadata = Column(JSON, nullable=True)
6767

6868
def set_audit_info(

0 commit comments

Comments
 (0)