commit 4c38d67a23c388853c970577aba83ab563aac811 Author: halo Date: Tue Sep 8 09:29:20 2026 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c2bf64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Python / local tooling +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.idea/ +.DS_Store + +# Local configuration and key material must never be committed. +.env +.env.* +!.env.example +*.pem +*.key +keystore/ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..fe258d5 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,7 @@ +# Agent 规则入口 + +本项目的唯一协作规则正文是 [AGENTS.md](AGENTS.md)。开始工作前请完整阅读并遵守它, +同时遵守上级目录适用的规则。 + +本文件保留用户约定的 `AGENT.md` 入口;规则只在 `AGENTS.md` 维护,避免两份内容漂移。 +项目设计见 [docs/architecture.md](docs/architecture.md),使用方式见 [README.md](README.md)。 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..62aee2c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# ituac-crypto-hub 开发约定 + +## 定位与边界 + +- 本仓库提供 ETH、后续 BTC 等链相关操作的 Python 能力库,不是交易所、交易策略或资产托管系统。 +- 默认中文沟通和文档,英文代码标识;仅修改本仓库内与当前任务相关的文件。 +- 开始前检查 `git status --short --branch`、HEAD(新仓库可能不存在)及相关差异。 + 保留用户已有的工作树和暂存区内容,不自动提交、推送、重置、清理、部署或操作数据库。 +- 仅讨论或只读请求不构成开发授权。新增依赖、框架和真实链操作应与明确需求匹配。 + +## 架构约束 + +- 使用 Python 3.11+、`src/core`、`src/eth` 同级包布局及 `pyproject.toml` 构建配置。 +- `core` 仅容纳已被实际需求证明的公共原语;不能反向依赖币种包或链 SDK。 +- `eth`、未来 `btc` 等包同级且互不依赖,各自拥有链模型、用例和适配器。 +- 不把 ETH 的账户、nonce、gas 等概念强加给其他链;不提前实现万能基类、注册中心或空服务层。 +- 外部节点、SDK、签名器接入时放入对应链的适配器,通过明确接口注入;导入包不得发起网络请求。 +- 只导出稳定公共接口;新增能力、依赖、执行方式或边界时同步更新 README 和架构文档。 + +## 金额、资金与安全 + +- 链上金额用最小单位整数;人类可读金额只接受明确的十进制字符串或 `Decimal`。 + 拒绝 `float`、非法数、负金额和不可精确表示的最小单位,不隐式四舍五入。 +- 不手写密码学算法,不把通用地址格式检查描述成完整校验;代币精度不能套用 ETH 原生币精度。 +- 不读取、打印、硬编码或提交真实私钥、助记词、RPC 凭据。忽略规则不能替代秘密审查。 +- 网络和 chain ID 必须显式指定并核验,不默认连接主网;测试默认离线,集成测试明确选择加入。 +- 签名、广播、授权合约、转账属于独立高风险操作,必须明确目标网络、账户、金额、费用及用户授权。 +- 后续实现必须分离构建、签名、广播、回执和最终确认;广播超时是结果未知,不代表失败,不能盲目重试。 +- 未支持或状态未知时明确报错,不能伪造余额、交易成功或确认状态。 + +## 代码与最小验证 + +- 公共函数需类型注解和契约说明;模块小而职责单一,异常类型明确,避免吞掉外部错误。 +- 单元测试使用标准库 `unittest`,按币种组织;先覆盖实际行为、错误边界与金额精度, + 不为增加测试数量堆积相同断言。网络替身的结果不能被描述为真实链验证。 +- 保留 `tests/__init__.py`,测试发现使用 `-t .`,避免 `tests/eth` 被当作顶层 `eth` 遮蔽源码包。 +- 当前不安装依赖即可在根目录执行: + + ```sh + PYTHONPATH=src python3 -m unittest discover -s tests -t . -v + python3 -m compileall -q src tests + git diff --check + ``` + +- 修改打包配置时,在本地虚拟环境验证安装与包导入,不修改全局 Python 环境。 +- 交付时检查新增文件和最终差异,说明已完成、实际验证、未验证范围;不把离线测试通过宣称为真实资金闭环。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..89fc2c3 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# ituac-crypto-hub + +面向 ETH、BTC 等虚拟货币相关操作的模块化 Python 能力库。先建设独立的 `eth` 包, +其他链随实际需求按同级包扩展。 + +## 当前范围 + +- 已实现:Python 包骨架、公共异常、ETH / Wei 精确单位转换、离线测试。 +- 尚未实现:节点 RPC、余额查询、钱包管理、交易签名与广播、ERC-20、BTC。 +- 当前无运行时第三方依赖;不是 HTTP 服务,导入及示例均不会连接网络或操作资金。 +- 原有 `main.py` 保留为 IDE 示例,不作为库入口。 + +## 快速开始 + +使用 Python 3.11+,从仓库根目录执行: + +```sh +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +python -c 'from eth import eth_to_wei; print(eth_to_wei("0.1"))' +``` + +首次安装可能需要联网获取构建工具。仅运行离线测试不需要安装依赖: + +```sh +PYTHONPATH=src python3 -m unittest discover -s tests -t . -v +``` + +`-t .` 让测试以 `tests.eth` 命名加载,避免覆盖源码中的 `eth` 包。 + +```python +from eth import eth_to_wei, wei_to_eth + +amount_wei = eth_to_wei("0.1") # 100000000000000000 +amount_eth = wei_to_eth(amount_wei) # Decimal('0.100000000000000000') +``` + +`eth_to_wei` 接受十进制字符串或 `Decimal`,`wei_to_eth` 接受非负整数(不接受 `bool`)。 +非法输入、负数及不足一个 Wei 的非零精度会抛出 `InvalidAmountError`,不会静默舍入。 +转换只表示金额,不验证余额或交易协议的数值上限,也不用于推断代币精度。 + +## 目录 + +```text +AGENT.md # 兼容入口,指向唯一规则正文 +AGENTS.md # 项目协作与安全规则 +docs/architecture.md # 分层、扩展方式和演进计划 +pyproject.toml # Python 包元数据及构建配置 +src/ + core/ # 公共原语(目前为异常) + eth/ # ETH 专属能力(目前为单位转换) +tests/ + __init__.py # 将测试放在 tests 命名空间 + eth/ # ETH 离线行为测试 +``` + +新增链与后续能力的边界见 [架构文档](docs/architecture.md)。 +ETH 单位依据:[Ethereum 官方 Ether 技术说明](https://ethereum.org/developers/docs/intro-to-ether)。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..36778ce --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,85 @@ +# 项目架构 + +## 1. 背景与本次决策 + +目标是沉淀可复用的虚拟货币操作解决方案,从 ETH 开始,逐步加入 BTC 等链。 +现有仓库只有 Python IDE 示例,故延续 Python,采用单仓库、单分发包、链能力模块化布局。 +`src` 仅作为源码目录,直接包含同级 `core`、`eth` 包,不再增加项目名命名空间。 +分发包名称仍为 `ituac-crypto-hub`,导入使用 `from eth import ...` / `from core import ...`。 +顶层包名较通用,使用项目独立虚拟环境;未来引入 SDK 时检查是否与 `eth`、`core` 同名冲突。 +本次状态:基础骨架已落地;下文节点、钱包和交易能力均为后续方向,不代表已经实现。 + +备选方案及取舍: + +- 暂不引入 FastAPI / 微服务:尚无 HTTP、认证或部署需求,先让能力能被脚本和未来服务复用。 +- 暂不为每条链建立独立仓库或分发包:降低早期维护成本;需独立发布或出现依赖冲突时再拆分。 +- 不建立统一交易大接口:仅有 ETH 首期需求,提前抽象容易把链特有语义泄漏到公共层。 +- 暂不安装链 SDK:首期只有离线基础能力;节点接入时再按官方维护状态、接口和测试支持选型。 + +## 2. 依赖方向 + +```text +调用方(脚本 / 未来 API / 任务) + ↓ ↓ + eth btc(未来) + ↓ ↓ + core +``` + +`core` 不导入任何币种包;币种包互不导入。当前 `eth.units` 依赖 `core.errors`, +`eth.__init__` 暴露经过测试的金额转换接口。 + +每条链在真实需求出现时再增长为: + +```text +eth/ + units.py # 已有:原生币单位转换 + models.py # 未来:本链请求、结果和值对象 + ports.py # 未来:节点与签名器接口,按能力拆分 + services/ # 未来:查询、交易等用例,不绑定 SDK + adapters/ # 未来:RPC / SDK / 外部签名器实现 +``` + +以上未来文件不预建空壳。外部适配器实现端口,调用入口负责组装和注入;用例不直接实例化 SDK。 +单元测试注入离线替身;集成测试独立配置网络及节点,不在包导入时建立连接。 + +## 3. 公共层与链专属层 + +| 归属 | 内容 | +| --- | --- | +| `core` | 当前公共异常;未来有真实复用证据的原语 | +| `eth` | 原生 ETH 单位、后续地址 / chain ID / 费用 / 交易与回执语义 | +| 未来 `btc` | BTC 自有单位、网络 / 地址 / UTXO / 手续费 / 交易模型 | +| 调用应用 | 身份权限、业务订单、持久化、调度、审计和部署,当前不实现 | + +链与资产分开:原生 ETH 的单位转换不能用于未知精度的代币。 +ERC-20 若进入范围,先归于 ETH / EVM 相关能力,不因币种名称拆成重复的链客户端。 +其他 EVM 链及共享 `evm` 层只有在需求明确、复用证据充分后再决定。 + +## 4. 后续操作的安全契约 + +- 节点 URL、目标网络、预期链标识显式配置,并核验节点身份;不得隐式落到主网。 +- 金额边界使用最小单位整数,展示才转换 Decimal;拒绝浮点和静默精度损失。 +- 密钥通过专门签名接口管理,不进入普通配置对象、日志、测试夹具或异常输出;不自行实现密码学。 +- 交易构建、费用估算、签名、广播、回执查询及最终确认分离。返回交易哈希不等于成功。 +- 对外错误需区分输入错误、节点拒绝、传输故障、响应格式错误、执行失败和结果未知。 +- 广播超时不自动重发资金操作;引入写能力前明确幂等、并发、费用上限、重试和链重组策略。 +- 真实转账或合约授权必须另行明确授权;离线测试不构成资金操作许可。 + +## 5. 增加 BTC 等链 + +1. 明确该链首批用例与非目标,验证协议和 SDK 官方资料。 +2. 新建 `src/btc/`,独立维护公共接口和链特有模型,不从 `eth` 继承。 +3. 新建 `tests/btc/`,验证单位、边界、失败路径;有节点适配器后再提供可选集成测试。 +4. SDK 依赖优先用按链可选依赖声明,防止 ETH 用户被迫安装 BTC 全部依赖。 +5. 只有已经出现的共同需求才提取到 `core`,同时更新此文档和 README。 + +## 6. 建议演进顺序(待具体需求确认) + +1. ETH 只读接入:节点身份、区块、原生币余额、交易及回执查询。 +2. ETH 写能力:构建和估算 → 独立签名器 → 显式广播 → 确认跟踪及异常恢复。 +3. 按需求扩展代币、事件查询等能力。 +4. BTC 首期只读能力,再按其自身模型补充资金操作。 + +验收分开记录源码 / 离线测试 / 本地链或测试网 / 真实节点 / 真实资金证据。 +目前只提供并验证基础库;不声明主网、RPC 或真实交易已验证。 diff --git a/main.py b/main.py new file mode 100644 index 0000000..94e3a87 --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +# This is a sample Python script. + +# Press ⌃R to execute it or replace it with your code. +# Press Double ⇧ to search everywhere for classes, files, tool windows, actions, and settings. + + +def print_hi(name): + # Use a breakpoint in the code line below to debug your script. + print(f'Hi, {name}') # Press ⌘F8 to toggle the breakpoint. + + +# Press the green button in the gutter to run the script. +if __name__ == '__main__': + print_hi('PyCharm') + +# See PyCharm help at https://www.jetbrains.com/help/pycharm/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18e09e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "ituac-crypto-hub" +version = "0.1.0" +description = "Modular cryptocurrency operation capabilities, starting with Ethereum" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +[tool.setuptools.package-data] +core = ["py.typed"] +eth = ["py.typed"] diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..49d279f --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,5 @@ +"""Shared primitives; must not depend on chain-specific packages.""" + +from .errors import CryptoHubError, InvalidAmountError + +__all__ = ["CryptoHubError", "InvalidAmountError"] diff --git a/src/core/errors.py b/src/core/errors.py new file mode 100644 index 0000000..cac5298 --- /dev/null +++ b/src/core/errors.py @@ -0,0 +1,9 @@ +"""Public exceptions shared by cryptocurrency capabilities.""" + + +class CryptoHubError(Exception): + """Base exception for errors raised by this library.""" + + +class InvalidAmountError(CryptoHubError, ValueError): + """Amount has an invalid type, sign, value, or smallest-unit precision.""" diff --git a/src/core/py.typed b/src/core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/eth/__init__.py b/src/eth/__init__.py new file mode 100644 index 0000000..5a70cbf --- /dev/null +++ b/src/eth/__init__.py @@ -0,0 +1,5 @@ +"""Ethereum-native capabilities (no RPC or signing implementation yet).""" + +from .units import WEI_PER_ETH, eth_to_wei, wei_to_eth + +__all__ = ["WEI_PER_ETH", "eth_to_wei", "wei_to_eth"] diff --git a/src/eth/py.typed b/src/eth/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/eth/units.py b/src/eth/units.py new file mode 100644 index 0000000..0434d9b --- /dev/null +++ b/src/eth/units.py @@ -0,0 +1,37 @@ +"""Exact native ETH conversions, independent of Decimal context precision.""" + +from decimal import Decimal, InvalidOperation + +from core import InvalidAmountError + +WEI_PER_ETH = 10**18 + + +def eth_to_wei(amount: str | Decimal) -> int: + """Convert non-negative ETH to Wei; reject float and fractional Wei. + + This is a unit conversion, not validation of transaction protocol limits. + """ + if not isinstance(amount, (str, Decimal)): + raise InvalidAmountError("ETH amount must be a decimal string or Decimal") + try: + value = Decimal(amount) + except InvalidOperation: + raise InvalidAmountError("ETH amount must be a valid decimal") from None + if not value.is_finite() or value < 0: + raise InvalidAmountError("ETH amount must be finite and non-negative") + + # Integer arithmetic avoids rounding under the caller's Decimal context. + numerator, denominator = value.as_integer_ratio() + wei, remainder = divmod(numerator * WEI_PER_ETH, denominator) + if remainder: + raise InvalidAmountError("ETH amount must be exactly representable in Wei") + return wei + + +def wei_to_eth(amount: int) -> Decimal: + """Convert non-negative integer Wei to exact ETH without rounding.""" + if isinstance(amount, bool) or not isinstance(amount, int) or amount < 0: + raise InvalidAmountError("Wei amount must be a non-negative integer") + integer = Decimal(amount).as_tuple() + return Decimal((integer.sign, integer.digits, -18)) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3cc7795 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test namespace, separate from the source packages of the same name.""" diff --git a/tests/eth/__init__.py b/tests/eth/__init__.py new file mode 100644 index 0000000..6c64f58 --- /dev/null +++ b/tests/eth/__init__.py @@ -0,0 +1 @@ +"""Ethereum unit tests, discoverable by standard-library unittest.""" diff --git a/tests/eth/test_units.py b/tests/eth/test_units.py new file mode 100644 index 0000000..9a5c0e2 --- /dev/null +++ b/tests/eth/test_units.py @@ -0,0 +1,46 @@ +"""Offline behavior and precision checks for native ETH amounts.""" + +import unittest +from decimal import Decimal, localcontext + +from core import CryptoHubError, InvalidAmountError +from eth import WEI_PER_ETH, eth_to_wei, wei_to_eth + + +class EthUnitsTest(unittest.TestCase): + def test_exact_conversions(self): + for eth, wei in [("0", 0), ("1", WEI_PER_ETH), ("0.1", 10**17), + ("0.000000000000000001", 1), ("1.0000000000000000000", WEI_PER_ETH)]: + with self.subTest(eth=eth): + self.assertEqual(eth_to_wei(eth), wei) + self.assertEqual(eth_to_wei(Decimal(eth)), wei) + self.assertEqual(wei_to_eth(wei), Decimal(eth)) + + def test_invalid_eth_is_rejected(self): + for amount in [0.1, True, 1, None, "", "invalid", "-1", "NaN", "sNaN", + "Infinity", "-Infinity", "0.0000000000000000001"]: + with self.subTest(amount=amount): + with self.assertRaises(InvalidAmountError): + eth_to_wei(amount) + + def test_invalid_wei_is_rejected(self): + for amount in [-1, True, False, 1.0, "1", Decimal("1"), None]: + with self.subTest(amount=amount): + with self.assertRaises(InvalidAmountError): + wei_to_eth(amount) + + def test_large_amount_round_trip_ignores_decimal_precision(self): + wei = 123456789012345678901234567890123456789 + expected = Decimal("123456789012345678901.234567890123456789") + with localcontext() as context: + context.prec = 6 + self.assertEqual(wei_to_eth(wei), expected) + self.assertEqual(eth_to_wei(expected), wei) + self.assertEqual(eth_to_wei(wei_to_eth(wei)), wei) + + def test_invalid_amount_is_public_library_error(self): + self.assertIsInstance(InvalidAmountError("invalid"), CryptoHubError) + + +if __name__ == "__main__": + unittest.main()