PHP对接EOS区块链:从RPC到签名推送的完整指南
我第一次在搜索框里敲下php eos的时候搜索结果有点滑稽——左边是 PHP heredoc 语法讲解右边是 EOS 区块链相关的帖子。这个组合并非巧合EOS在 PHP 里是合法的 heredoc 定界符EOS 同时又是一条公链的名字。对于一个想用 PHP 接 EOS 的开发者这个搜索词恰好概括了整件事的起点你需要搞清楚一个 PHP 字符串标记和一个区块链开发包之间到底隔着多少步骤。这篇东西就是我折腾 EOS 区块链 PHP 开发包的完整记录从 RPC 对接、私钥解析、交易构造成签名推送到 ABI 序列化和生产部署的坑。适合有 PHP 基础、想进区块链 dApp 开发或者正在评估“EOS 到底能不能用 PHP 玩”的读者。我尽量把每一步为什么这么做讲清楚不是丢一堆 curl 命令让你抄。1. 双关开局EOS与 EOS 开发包的关系1.1 PHP heredoc 语法中 EOS 的真实身份在 PHP 里EOS是 heredoc 结构EOS只是你随手写的标识符它可以是EOT、HTML、SQL没有人规定它必须代表什么。PHP 解析器遇到之后会把紧跟的标识符当作字符串定界符直到出现一个独立成行的相同标识符为止。?php $text EOS 你好这里是 heredoc 字符串。 EOS; echo $text;所以单看语法php eos和“EOS 区块链”没有任何关系。但搜索引擎不管这些它会把eos作为关键词去匹配结果两边的内容全混在一起。我反而是从这种混乱里得到了一个直觉与其去查“EOS 开发包有哪些”不如顺着这个双关把 PHP 调用 EOS 链路的最小子集亲手打通。这篇文章的骨架就是这么来的。补充一个 PHP 版本相关的细节PHP 7.3 之前heredoc 的闭合标识符必须顶格写不能缩进很多人在模板拼 SQL 时被这里卡过。7.3 之后允许缩进闭合标识符前可以有空格但必须独立一行。这个老坑和 EOS 没关系但既然标题里出现了 heredoc就顺手提一句省得你在调试时误以为是自己的开发包写错了。1.2 这个标题下真正要解决的需求把标题拆开其实藏着三层需求第一层什么是 EOS它是一套基于 EOSIO 技术栈的公链智能合约一般用 C 编写运行在 WebAssembly 虚拟机上。第二层PHP 能做什么PHP 不能直接写 EOS 智能合约但可以做链外服务——查链上数据、构造交易、签名、广播。第三层开发包解决了什么它把上面这些能力封装成 PHP 类让你不用手撸二进制序列化不用到处查 RPC 文档。我见过不少项目方智能合约团队写起来很快但 PHP 后端一对接链上数据就开始头疼。真正落地时你至少需要一个能完成三件事的客户端能调节点接口、能解析密钥、能签名广播。下面几节我就按这个顺序来拆。2. PHP 开发者进入 EOS 链的正确姿势RPC 是你的大门2.1 链外服务的定位nodeos 的 HTTP 接口才是双方的交汇点很多 PHP 朋友一听到区块链第一反应是去找 PHP 扩展仿佛装上一个.so文件就能神功附体。但 EOS 官方并没有为 PHP 维护这样的扩展也不需要。EOSIO 节点程序nodeos会暴露一套 HTTP RPC 接口专门给外部应用调用。你完全可以把它理解成一个远程数据库服务PHP 这边用 cURL 发 HTTP 请求拿 JSON 回来仅此而已。打个比方EOS 链像一个封闭的数据库服务器nodeos是数据库进程RPC 接口是它对外开的端口而 PHP 是业务应用。你和链上合约打交道的唯一入口就是这些 HTTP 端点不需要什么黑魔法协议。官方 SDK 里做得最完整的eosjs也是同样的思路底层就是一个封装好的 HTTP 客户端。使用这种架构有一个很实际的好处排障时可以用 curl 直接打接口快速定位是节点问题还是业务代码问题。我最常干的事就是先把 RPC 返回的 JSON 原样存到日志里再决定要不要怀疑自己的签名逻辑。2.2 核心 RPC 端点速览读操作可以有多轻EOSIO 节点常见的 RPC 接口分布在/v1/chain、/v1/history等下我列一下日常用得最多的几个端点作用典型场景/v1/chain/get_info获取链基本信息拿 chain_id、最新区块、不可逆区块高度/v1/chain/get_block按区块号或哈希取区块前端展示交易流水、核对账本/v1/chain/get_account查询账户资源与权限查询余额、带宽、CPU 情况/v1/chain/get_currency_balance查询代币余额常见于资产类应用/v1/chain/abi_json_to_bin把 JSON 参数序列化为二进制数据构造 action 的 data 字段/v1/chain/push_transaction推送签名后的交易转账、调用合约读操作真的轻到令人发指。比如查一个账户的 EOS 余额PHP 里一个 cURL 请求就够了$resp file_get_contents(https://你的节点节点/v1/chain/get_currency_balance, false, stream_context_create([ http [ method POST, header Content-Type: application/json, content json_encode([ code eosio.token, account youraccount ]), ], ]));如果只是做链上数据展示、余额查询、区块监控你甚至不需要引入任何开发包。真正需要“开发包”的地方在写操作构造交易、序列化、签名、广播每一步都有二进制细节要处理。这也是我决定手写一个小客户端的原因——读操作不值钱写操作才是硬骨头。3. 从零手写轻量客户端私钥、转账与广播3.1 客户端架构与依赖选型手写客户端时我没有把逻辑全塞进一个类而是拆成了三个职责明确的组件RpcClient只管 HTTP 请求、超时、错误码映射。KeyManager负责 WIF 私钥解析、公钥推导、base58 编解码。TransactionBuilder负责构造 action、序列化交易、调用签名模块。依赖方面PHP 8.1 是我的主力版本7.4 上面跑问题也不大。必要的扩展是curl、gmp、mbstring。签名部分我用了simplito/elliptic-php这个库来做 secp256k1 签名因为纯 PHP 手写椭圆曲线签名不现实也没有必要。Composer 一行搞定composer require simplito/elliptic-php为什么这么拆因为 EOS 的接入点经常会变。今天用一个公共节点明天可能换自建节点测试网、主网、侧链的 chain_id 还不一样。把 RPC 层单独隔离出来以后换节点只需要改一个 URL。签名逻辑单独放一个类万一将来要切换到硬件钱包替换面很小。RpcClient的核心代码其实很短class EosRpcClient { public function call(string $endpoint, array $params []): array { $ch curl_init($this-baseUri . $endpoint); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params)); curl_setopt($ch, CURLOPT_HTTPHEADER, [Content-Type: application/json]); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $resp curl_exec($ch); $code curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $data json_decode($resp, true); if ($code 400) { throw new RuntimeException($data[error][what] ?? EOS RPC error); } return $data; } }这段代码看起来简单但有几个细节很重要超时一定要设置公共节点经常慢错误码不要只判断$code 200有些节点会返回 202 表示交易接收成功但未最终确认error.what字段里往往有比状态码更有用的信息这也是我在生产日志里最依赖的字段。3.2 私钥处理EOS 与比特币 WIF 的细微差别EOS 的私钥格式和比特币非常像但有一个关键差异让很多 PHP 老手栽过跟头EOS 使用 RIPEMD160 做 checksum而不是比特币的 double SHA256。EOS 私钥有两种外壳老式的是5开头的 WIF 字符串新式的是PVT_K1_前缀。剥掉前缀之后底层内容是一样的版本字节0x8032 字节私钥4 字节 checksum。checksum 计算方式是ripemd160(0x80 . 私钥)取前 4 字节。解析逻辑大致是这样$decoded base58_decode($payload); // 自行实现或用库 $version $decoded[0]; // 应该是 0x80 $key substr($decoded, 1, 32); $checksum substr($decoded, 33, 4); $expect substr(hash(ripemd160, \x80 . $key, true), 0, 4); if (!hash_equals($expect, $checksum)) { throw new RuntimeException(WIF checksum mismatch); }注意这里不能用比特币的 WIF 解析库否则 checksum 怎么都对不上。我第一次用比特币的解码逻辑去解 EOS 私钥报错报了一个多小时最后怀疑人生翻源码才发现是 checksum 算法不同。现在我把这类差异写进了团队的知识库每次新人接手 EOS 项目都会先看一遍。公钥也是类似EOS 老式公钥以EOS开头新式以PUB_K1_开头payload 是 33 字节的 secp256k1 压缩公钥后面跟 4 字节ripemd160(公钥)前四位的 checksum再做 base58。3.3 构造并广播一笔 transfer 的完整流程拿最常见的一笔 EOS 代币转账来走一遍完整流程。EOS 上的转账不是原生交易而是调用eosio.token合约的transferaction所以你要按智能合约的方式构造交易。第一步从get_info拿到链的基础信息$info $client-call(/v1/chain/get_info); $chainId $info[chain_id]; $headBlockId $info[head_block_id];第二步从head_block_id里取出交易防重放需要的两个字段。参考eosjs的做法ref_block_num是区块 ID 前 8 个十六进制字符转十进制ref_block_prefix是紧接着的 8 个十六进制字符转十进制$refBlockNum hexdec(substr($headBlockId, 0, 8)); $refBlockPrefix hexdec(substr($headBlockId, 8, 8));第三步调用abi_json_to_bin将 transfer 的参数序列化成二进制数据$bin $client-call(/v1/chain/abi_json_to_bin, [ code eosio.token, action transfer, args [ from $from, to $to, quantity $quantity, memo $memo, ], ]); $data $bin[binargs];第四步组装 action 和交易结构。EOS 的 action 需要声明调用哪个合约、哪个 action、哪个账户和权限data 字段就是上面拿到的二进制字符串$action [ account eosio.token, name transfer, authorization [ [actor $from, permission active], ], data $data, ]; $transaction [ expiration time() 60, ref_block_num $refBlockNum, ref_block_prefix $refBlockPrefix, context_free_actions [], actions [$action], max_cpu_usage_ms 0, max_net_usage_words 0, ];这里有个细节值得注意expiration的单位是秒类型是 EOSIO 里的time_point_sec。我见过不少 PHP 实现这里直接用毫秒结果链上永远提示交易过期。刚开始接触的时候把time()乘 1000 传进去然后对着错误响应急得挠头后来翻了链上序列化定义才意识到。第五步把交易序列化得到packed_trx计算摘要签名。具体逻辑下一节单独拆。第六步推送交易$result $client-call(/v1/chain/push_transaction, [ signatures [$signature], compression 0, packed_context_free_data , packed_trx $packedTrx, ]);packed_trx是十六进制字符串signatures是形如SIG_K1_...的字符串数组。这是我最常打交道的两个字段也是所有崩溃集中爆发的区域。4. 签名链路PHP 里最容易翻车的环节4.1 交易摘要的构成chain_id packed_trxEOS 交易签名时计算的摘要不是交易内容的简单哈希而是$digest hash(sha256, $chainId . $packedTrx, true);chain_id是 64 个十六进制字符的二进制形式packed_trx是序列化后的交易字节。把 chain_id 放进摘要里是为了防止你在测试网签出来的交易被搬到主网重放——每一条链的 chain_id 都不同签名摘要对不上交易就是非法的。packed_trx的序列化顺序是固定的expiration、ref_block_num、ref_block_prefix、max_net_usage_words、max_cpu_usage_ms、delay_sec、context_free_actions、actions、transaction_extensions。其中 action 里还有 authorization 数组data 是变长字节。这些二进制布局本身不难理解但手撸容易漏字段少一个字节交易就废了。我的建议是不要用“纯手写拼字节”的方式来产出packed_trx而是先把交易序列化器做成一个独立模块单测覆盖住。否则你很难分辨是签名错了还是序列化错了。4.2 从 secp256k1 签名到 SIG_K1_ 字符串生产环境里EOS 主流的签名曲线是 secp256k1和比特币相同。用simplito/elliptic-php签名时要确保私钥是 32 字节二进制摘要也是 32 字节二进制转成的 hex$ec new \Elliptic\EC(secp256k1); $key $ec-keyFromPrivate(bin2hex($privateKey)); $sig $key-sign(bin2hex($digest), [canonical true]); $r pack(C*, ...$sig-r-toArray(be, 32)); $s pack(C*, ...$sig-s-toArray(be, 32)); $recid $sig-recoveryParam;这里有两个非常关键的坑canonical: true必须打开。它保证签名是 low-s 格式。如果签名是 high-s节点会直接拒绝。PHP 的椭圆曲线库默认不一定给你规范签名这个选项就是干这个用的。EOS 的签名不是 DER 编码而是固定 65 字节的紧凑格式。字节布局是header r s其中header 27 recoveryParam 4。那个4是压缩公钥标记EOS 默认用压缩公钥所以这个 4 不能省。组装完 65 字节后再附上ripemd160(签名内容)的前 4 字节做 checksumbase58 编码前面加SIG_K1_$compactSignature chr(27 $recid 4) . $r . $s; $checksum substr(hash(ripemd160, $compactSignature, true), 0, 4); $signature SIG_K1_ . base58_encode($compactSignature . $checksum);如果你拿到的签名格式和 EOS 节点对不上问题十有八九出在用了 DER 编码没转紧凑格式header 里少了压缩标记或者把 r、s 的顺序搞反。我每次排查签名问题时都会先打印签名长度不是 65 字节就直接放弃节省了很多时间。4.3 常见签名失败的症状与排查签名失败在节点返回里通常不会说“你的 r 不对”而是给一个让人摸不着头脑的错误。我归纳了几类高频症状症状可能原因节点返回signature is not canonical签名是 high-s没开 canonical 选项节点返回public key does not match signature私钥对应的公钥和签名不一致通常是用错曲线比如用成 secp256r1节点返回signature for transaction was not first签名数组顺序问题或者多签时签名顺序与权限不匹配自己验签通过但节点不接受摘要计算错了最常见是 chain_id 没转二进制直接拼了十六进制字符串一个特别容易忽略的细节是PHP 的很多库在 hex 和二进制之间来回转很容易在某个环节多一次bin2hex。比如你hash(sha256, $data)拿到的是十六进制字符串如果再把它hex2bin一次再签名签名结果就是另一笔交易。我自己的排查习惯是在生产环境把digest的 hex 打出来拿到 eosjs 或 cleos 那边去验一遍两边一致再查后面的签名逻辑。5. ABI 序列化为什么你的 data 总在报错5.1 什么是 ABI以及序列化的最小认知ABI 全称 Application Binary Interface可以理解成智能合约对外暴露的“类型签名表”。EOS 智能合约的 action 参数经过 ABI 定义后以太坊那种 JSON 形式的参数在 EOS 上是不合法的节点要求的是经过紧凑编码的二进制 data 字段。和以太坊 ABI 相似之处是都要编码类型但细节差别很大。EOS 的常见 ABI 类型有name、asset、symbol、public_key、time_point、bytes等。其中两个最容易出错nameEOS 账户名在序列化时不是直接存字符串而是转成一个 64 位整数。asset不是字符串而是“数量 精度 符号”的结构例如1.0000 EOS在二进制里是一个 8 字节整数10000精度 4符号EOS。这两个类型如果你自己实现序列化name 至少 30 行代码asset 还得处理小数精度。所以一开始接触 EOS 时我强烈建议先用节点上的abi_json_to_bin让节点帮你做序列化。5.2 用 abi_json_to_bin 快速拿 binargsabi_json_to_bin的设计非常贴心你只需要给它合约名、action 名、参数对象它就返回已经序列化好的二进制字符串。上一节代码里我们已经用过$bin $client-call(/v1/chain/abi_json_to_bin, [ code eosio.token, action transfer, args [ from $from, to $to, quantity $quantity, memo $memo, ], ]); $data $bin[binargs]; // 类似 0080bafaf0a1... 的十六进制字符串这个方案在开发阶段最省事。但你要注意它依赖一个可信且可用的节点。如果你在一个内网环境部署或者节点服务不稳定序列化这一步就成了单点。更麻烦的是公共节点通常有限流高频交易场景下你不能每次都去请求序列化。所以我最终的线上方案是开发阶段全走abi_json_to_bin跑通业务逻辑后再把序列化逻辑逐步替换成本地实现。先把业务闭环跑起来再去做性能优化是区块链项目里很稳的路径。5.3 离线序列化的场景与 Name/Asset 特化离线序列化最常见的使用场景是冷钱包、批量代付、以及不想信任公共节点的情况。这时你需要自己实现 name 和 asset 的编码。name 编码我贴一个简化但能用的版本。EOS 账户名的字符集是 31 个字符点号、数字1到5、小写字母a到z。前 12 个字符每位占 5 位第 13 个字符占 4 位共 64 位function encodeName(string $name): string { $map .12345abcdefghijklmnopqrstuvwxyz; $name str_pad(substr($name, 0, 13), 13, ., STR_PAD_RIGHT); $value 0; for ($i 0; $i 13; $i) { $c strpos($map, $name[$i]); if ($i 12) { $value | $c (64 - 5 * ($i 1)); } else { $value | $c; } } return pack(Q, $value); // 64 位小端 }asset 的编码就不贴全部代码了但你要记住核心金额先乘上精度变成整数8 字节小端符号最多 7 个字符必须是大写和精度一起编码进 8 字节的 symbol 结构中。比如1.0000 EOS在序列化前数量是10000精度是4符号是EOS。离线序列化最容易出的问题是类型长度搞错。EOS 的二进制编码里很多字段是“可变长度无符号整数”varuint32比如数组长度、字符串长度而不是固定的 4 字节。我见过一个项目所有数组长度都按 4 字节写结果abi_json_to_bin出来的和本地序列化出来的总是差几个字节。遇到这种问题最简单的验证方式就是拿本地序列化结果和节点返回的 binargs 逐字节对比找出第一个差异字节很快就能定位到是哪个字段的编码规则错了。6. 轮子与自研的取舍PHP EOS 生态盘点6.1 社区开发包概况手写客户端的过程中我也适度调研过 PHP 生态里已有的 EOS 开发包目的不是否定它们而是知道哪些场景可以直接用轮子哪些必须自己控。我实际接触过的两个库是tp-lab/eos-php和block-widget/eosio-php。前者对主网转账的封装相对完整离线签名示例文档也比较多后者的 RPC 层做得早覆盖了不少接口但项目更新节奏不算快。两者在 ABI 序列化上都依赖节点或者内置部分序列化器取决于版本。用社区库的好处很明显你不用自己处理 base58、WIF 解析、曲线签名这些累活。但代价也很现实很多库默认只覆盖 eosio.token 的转账和少量常用合约遇到自定义合约的复杂数据类型要么改库要么自己补序列化逻辑。这时候你对底层掌握越清楚改起来越有把握。6.2 选型逻辑与我的取舍场景推荐做法理由只做余额查询、区块同步自写一个 RpcClient 就够了几十行代码无依赖可控标准 EOS 转账业务简单用成熟库快速上线省去签名链路的研发时间自定义合约、复杂权限体系自研或深度改造库往往跟不上自定义 ABI 类型冷钱包、离线签名必须理解底层甚至自研离线环境没有节点帮你序列化我自己在实际项目里的选择是一个自写的轻量客户端作为基础依赖成熟库作为参考实现。也就是说我会先读懂库是怎么处理签名和序列化的然后按项目具体需求改造出自己的版本。这个过程确实比直接composer require一个库要慢但它能保证出了问题你能定位到代码的哪一行。还有一个很实际的原因PHP EOS 开发包不像以太坊的 web3 生态那么官方便利很多库的 issue 区躺着长期没人回的 bug。你对底层理解越深越不会被一个冻结的依赖卡死项目进度。7. 生产环境避坑节点、私钥与不可逆确认7.1 私钥安全开发包再完善也救不了硬编码区块链开发包里处理私钥再流畅私钥本身放在哪才是更大的问题。我见过有人在配置文件里写死一个主网私钥结果代码仓库被拉取后就泄露了。这个风险比任何签名 bug 都致命因为它不是报错而是资产损失。我的基本要求是三条私钥永远不进代码仓库通过环境变量、密钥管理服务或独立的密钥存储接口注入。权限最小化。业务功能能用一个低权限子密钥解决的就不要用账户的 owner 私钥天天跑业务。线上日志里禁止打印私钥、签名前的私钥材料、或者任何能推导出私钥的中间数据。测试网和主网的私钥必须物理隔离这一条我吃过亏。测试网私钥一旦误用在主网签名广播最轻是交易失败重则资产归零。开发包是不背这个锅的。7.2 节点容灾与重试策略公共 RPC 节点看着方便但生产环境千万别只挂一个。节点维护、限流、网络波动都会让你的服务突然断链。我的做法是维护一个 RPC 节点列表请求失败时自动切换$nodes [ https://node1.example.com, https://node2.example.com, ]; foreach ($nodes as $node) { try { $client new EosRpcClient($node); $client-call(/v1/chain/get_info); return $client; } catch (Throwable $e) { continue; } } throw new RuntimeException(all EOS nodes unavailable);重试策略要小心一个陷阱读接口重试没问题但push_transaction如果已经发送出去了只是响应超时你再重试同一笔交易节点会基于交易 ID 去重通常不会重复扣款。但如果你重试时重新构造交易expiration、ref_block 可能已经变了生成的是一个新的交易 ID这就可能出现两笔重复交易。业务层必须自己保证幂等否则开发包是无能为力的。7.3 不可逆确认交易被广播不等于交易最终确认push_transaction返回成功表示节点接收并执行了这笔交易但不代表它已经进入不可逆区块。EOS 的共识机制下有不可逆区块这个概念只有区块被确认后交易才真正稳定。判断交易是否不可逆的方式很直接记录交易所在区块号然后用get_info里返回的last_irreversible_block_num对比。如果交易区块号已经小于等于这个高度交易基本不可逆。或者用 history API 查询交易看它的block_num是否已经小于等于不可逆高度。生产环境里的常见场景是用户 APP 显示转账成功但隔几秒链上回滚了。对于小额业务也许可以接受但金额稍大一定要以后端确认任务为准。我通常会做一个定时任务扫描“已推送未确认”的交易表等不可逆确认后再更新业务状态。最后再分享一个小技巧EOS 的节点错误返回里error.details往往带着最直接的信息比如缺失字段、序列化不一致。排查时先把这一层日志打全比反复看what字段有用得多。这套 PHP 开发链路的坑多数不是算法多深而是二进制细节和节点行为习惯的问题多打印原始报文、多对比官方 SDK 的逻辑你会比任何人更快定位到自己代码的第几行。