diff --git a/.env.example b/.env.example index 97786b5..38f6f71 100644 --- a/.env.example +++ b/.env.example @@ -20,9 +20,14 @@ RPC_URL=http://anvil:8545 # Development only PRIVATE_KEY=0x... +# Leave empty on the first deployment. Production deployments automatically +# populate these values from address-data/addresses.json to preserve balances. +MOCK_USDC_ADDRESS= +MOCK_BOND_ADDRESS= + # ========================= # Docker # ========================= ANVIL_CONTAINER_NAME=nettedx-anvil -DEPLOYER_CONTAINER_NAME=nettedx-deployer \ No newline at end of file +DEPLOYER_CONTAINER_NAME=nettedx-deployer diff --git a/.github/workflows/Docker-Deploy.yml b/.github/workflows/Docker-Deploy.yml index 7aa94ea..67f302f 100644 --- a/.github/workflows/Docker-Deploy.yml +++ b/.github/workflows/Docker-Deploy.yml @@ -69,7 +69,7 @@ jobs: host: ${{ secrets.VPC_HOST }} username: ${{ secrets.VPC_USERNAME }} password: ${{ secrets.VPC_PASSWORD }} - source: ".env.example,docker-compose.prod.yml" + source: ".env.example,docker-compose.prod.yml,script/token_state_check.py" target: "/opt/NettedX/NettedX-Blockchain" - name: Deploy to VPC @@ -90,16 +90,67 @@ jobs: echo "Deploying ${IMAGE}" + ADDRESS_FILE="/opt/NettedX/NettedX-Blockchain/address-data/addresses.json" + TOKEN_STATE_FILE="/tmp/nettedx-token-state-before.json" + HOST_RPC_URL="http://127.0.0.1:8545" + REUSING_MOCK_TOKENS="false" + + # Preserve the currently deployed mock tokens and their complete state. + if test -f "${ADDRESS_FILE}"; then + export MOCK_USDC_ADDRESS="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "${ADDRESS_FILE}" "NETTEDX_MOCK_USDC_CONTRACT_ADDRESS")" + export MOCK_BOND_ADDRESS="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "${ADDRESS_FILE}" "NETTEDX_MOCK_BOND_CONTRACT_ADDRESS")" + REUSING_MOCK_TOKENS="true" + + echo "Reusing MockUSDC: ${MOCK_USDC_ADDRESS}" + echo "Reusing MockBond: ${MOCK_BOND_ADDRESS}" + + python3 script/token_state_check.py snapshot \ + --rpc-url "${HOST_RPC_URL}" \ + --usdc "${MOCK_USDC_ADDRESS}" \ + --bond "${MOCK_BOND_ADDRESS}" \ + --state-file "${TOKEN_STATE_FILE}" + else + echo "No previous address file found; mock tokens will be deployed." + fi + docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d + DEPLOYER_ID="$(docker compose -f docker-compose.prod.yml ps -a -q deployer)" + test -n "${DEPLOYER_ID}" + DEPLOY_EXIT_CODE="$(docker wait "${DEPLOYER_ID}")" + + if test "${DEPLOY_EXIT_CODE}" != "0"; then + docker logs "${DEPLOYER_ID}" + echo "Contract deployment failed with exit code ${DEPLOY_EXIT_CODE}." + exit 1 + fi + docker compose -f docker-compose.prod.yml ps + test -f "${ADDRESS_FILE}" + + DEPLOYED_USDC_ADDRESS="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "${ADDRESS_FILE}" "NETTEDX_MOCK_USDC_CONTRACT_ADDRESS")" + DEPLOYED_BOND_ADDRESS="$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "${ADDRESS_FILE}" "NETTEDX_MOCK_BOND_CONTRACT_ADDRESS")" + + if test "${REUSING_MOCK_TOKENS}" = "true"; then + python3 script/token_state_check.py verify \ + --rpc-url "${HOST_RPC_URL}" \ + --usdc "${DEPLOYED_USDC_ADDRESS}" \ + --bond "${DEPLOYED_BOND_ADDRESS}" \ + --state-file "${TOKEN_STATE_FILE}" + else + python3 script/token_state_check.py snapshot \ + --rpc-url "${HOST_RPC_URL}" \ + --usdc "${DEPLOYED_USDC_ADDRESS}" \ + --bond "${DEPLOYED_BOND_ADDRESS}" \ + --state-file "${TOKEN_STATE_FILE}" + fi + # ========================================================= # Update backend .env with deployed contract addresses # ========================================================= - ADDRESS_FILE="/opt/NettedX/NettedX-Blockchain/address-data/addresses.json" ENV_FILE="/opt/NettedX/nettedx-back/.env" echo "Reading contract addresses from ${ADDRESS_FILE}" @@ -200,4 +251,4 @@ jobs: docker compose -f docker-compose.prod.yml restart docker compose -f docker-compose.prod.yml ps - echo "Deployment completed successfully." \ No newline at end of file + echo "Deployment completed successfully." diff --git a/README.md b/README.md index 6f952bc..530b12c 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,20 @@ cp .env.example .env Change the variables in `.env` as needed. +`MOCK_USDC_ADDRESS` and `MOCK_BOND_ADDRESS` control whether deployment creates +new mock tokens or reuses existing ones: + +- Leave both values empty for the first deployment on a new chain. +- Set them to deployed contract addresses to preserve token balances and total supply. +- If a configured address has no contract code, deployment fails instead of silently + replacing the token. + +Production releases automatically load both addresses from +`address-data/addresses.json`, snapshot all managed account balances and token +supplies, and verify that the complete mock-token state is unchanged after deployment. +Only an intentional chain reset should remove the address file and deploy new mock +tokens. + ### Build and Run ```bash @@ -266,7 +280,8 @@ NettedX-Blockchain/ │ ├── unit/ │ └── integration/ ├── script/ -│ └── Deploy.s.sol +│ ├── Deploy.s.sol +│ └── token_state_check.py ├── lib/ │ ├── forge-std/ │ └── openzeppelin-contracts/ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 21d3432..35b43a7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,6 +60,8 @@ services: environment: RPC_URL: ${RPC_URL} PRIVATE_KEY: ${PRIVATE_KEY} + MOCK_USDC_ADDRESS: ${MOCK_USDC_ADDRESS:-} + MOCK_BOND_ADDRESS: ${MOCK_BOND_ADDRESS:-} entrypoint: - forge @@ -73,4 +75,4 @@ services: volumes: - ./broadcast:/app/broadcast - - ./address-data:/app/address-data \ No newline at end of file + - ./address-data:/app/address-data diff --git a/docker-compose.yml b/docker-compose.yml index 0b4fe8b..3d3d102 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,8 @@ services: environment: RPC_URL: ${RPC_URL} PRIVATE_KEY: ${PRIVATE_KEY} + MOCK_USDC_ADDRESS: ${MOCK_USDC_ADDRESS:-} + MOCK_BOND_ADDRESS: ${MOCK_BOND_ADDRESS:-} entrypoint: - forge diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 710d62e..956aeaf 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -32,16 +32,30 @@ contract Deploy is Script { vm.startBroadcast(deployerPrivateKey); // ========================================================= - // 1. Deploy MockUSDC + // 1. Reuse or deploy MockUSDC // ========================================================= - usdc = new MockUSDC(deployer); + address existingUsdc = vm.envOr("MOCK_USDC_ADDRESS", address(0)); + + if (existingUsdc == address(0)) { + usdc = new MockUSDC(deployer); + } else { + require(existingUsdc.code.length > 0, "MockUSDC is not deployed"); + usdc = MockUSDC(existingUsdc); + } // ========================================================= - // 2. Deploy MockBond + // 2. Reuse or deploy MockBond // ========================================================= - bond = new MockBond(deployer); + address existingBond = vm.envOr("MOCK_BOND_ADDRESS", address(0)); + + if (existingBond == address(0)) { + bond = new MockBond(deployer); + } else { + require(existingBond.code.length > 0, "MockBond is not deployed"); + bond = MockBond(existingBond); + } // ========================================================= // 3. Deploy Settlement diff --git a/script/token_state_check.py b/script/token_state_check.py new file mode 100644 index 0000000..65aa287 --- /dev/null +++ b/script/token_state_check.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Snapshot and verify reusable mock-token state around a deployment.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import urllib.request +from pathlib import Path +from typing import Any + +ADDRESS_PATTERN = re.compile(r"^0x[0-9a-fA-F]{40}$") +SELECTORS = { + "name": "0x06fdde03", + "symbol": "0x95d89b41", + "decimals": "0x313ce567", + "total_supply": "0x18160ddd", + "balance_of": "0x70a08231", +} + + +class RpcClient: + def __init__(self, url: str) -> None: + self.url = url + self.request_id = 0 + + def call(self, method: str, params: list[Any]) -> Any: + self.request_id += 1 + payload = json.dumps( + { + "jsonrpc": "2.0", + "id": self.request_id, + "method": method, + "params": params, + } + ).encode() + request = urllib.request.Request( + self.url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=15) as response: + result = json.load(response) + if "error" in result: + raise RuntimeError(f"RPC {method} failed: {result['error']}") + return result["result"] + + +def normalize_address(value: str) -> str: + if not ADDRESS_PATTERN.fullmatch(value): + raise ValueError(f"Invalid address: {value}") + return value.lower() + + +def decode_uint(value: str) -> int: + return int(value, 16) + + +def decode_string(value: str) -> str: + raw = bytes.fromhex(value.removeprefix("0x")) + if len(raw) < 64: + raise ValueError("Invalid ABI string response") + offset = int.from_bytes(raw[:32], "big") + length = int.from_bytes(raw[offset : offset + 32], "big") + return raw[offset + 32 : offset + 32 + length].decode("utf-8") + + +def eth_call(client: RpcClient, contract: str, data: str) -> str: + return client.call("eth_call", [{"to": contract, "data": data}, "latest"]) + + +def read_token(client: RpcClient, address: str, accounts: list[str]) -> dict[str, Any]: + address = normalize_address(address) + code = client.call("eth_getCode", [address, "latest"]) + if code == "0x": + raise RuntimeError(f"No contract code at {address}") + + balances = {} + for account in accounts: + encoded_account = account.removeprefix("0x").rjust(64, "0") + balances[account] = decode_uint( + eth_call(client, address, SELECTORS["balance_of"] + encoded_account) + ) + + return { + "address": address, + "code_sha256": hashlib.sha256(bytes.fromhex(code.removeprefix("0x"))).hexdigest(), + "name": decode_string(eth_call(client, address, SELECTORS["name"])), + "symbol": decode_string(eth_call(client, address, SELECTORS["symbol"])), + "decimals": decode_uint(eth_call(client, address, SELECTORS["decimals"])), + "total_supply": decode_uint(eth_call(client, address, SELECTORS["total_supply"])), + "balances": balances, + } + + +def read_state(client: RpcClient, usdc: str, bond: str) -> dict[str, Any]: + accounts = [normalize_address(item) for item in client.call("eth_accounts", [])] + if not accounts: + raise RuntimeError("RPC returned no managed accounts") + + return { + "chain_id": decode_uint(client.call("eth_chainId", [])), + "accounts": accounts, + "tokens": { + "usdc": read_token(client, usdc, accounts), + "bond": read_token(client, bond, accounts), + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("snapshot", "verify")) + parser.add_argument("--rpc-url", required=True) + parser.add_argument("--usdc", required=True) + parser.add_argument("--bond", required=True) + parser.add_argument("--state-file", required=True, type=Path) + args = parser.parse_args() + + state = read_state(RpcClient(args.rpc_url), args.usdc, args.bond) + + if args.mode == "snapshot": + args.state_file.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8") + print( + "Saved token state: " + f"chain={state['chain_id']} accounts={len(state['accounts'])} " + f"usdc={state['tokens']['usdc']['address']} " + f"bond={state['tokens']['bond']['address']}" + ) + return + + expected = json.loads(args.state_file.read_text(encoding="utf-8")) + if state != expected: + raise RuntimeError("Reusable token address or state changed during deployment") + + print( + "Verified reusable token state: " + f"accounts={len(state['accounts'])} " + f"usdc_supply={state['tokens']['usdc']['total_supply']} " + f"bond_supply={state['tokens']['bond']['total_supply']}" + ) + + +if __name__ == "__main__": + main()