Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
DEPLOYER_CONTAINER_NAME=nettedx-deployer
57 changes: 54 additions & 3 deletions .github/workflows/Docker-Deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
Expand Down Expand Up @@ -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."
echo "Deployment completed successfully."
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -266,7 +280,8 @@ NettedX-Blockchain/
│ ├── unit/
│ └── integration/
├── script/
│ └── Deploy.s.sol
│ ├── Deploy.s.sol
│ └── token_state_check.py
├── lib/
│ ├── forge-std/
│ └── openzeppelin-contracts/
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -73,4 +75,4 @@ services:

volumes:
- ./broadcast:/app/broadcast
- ./address-data:/app/address-data
- ./address-data:/app/address-data
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions script/Deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 148 additions & 0 deletions script/token_state_check.py
Original file line number Diff line number Diff line change
@@ -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()
Loading