首页
/ Union Drip:构建支持多链多资产的 Cosmos 水龙头(GraphQL + 批量 MultiSend 实现解析)

Union Drip:构建支持多链多资产的 Cosmos 水龙头(GraphQL + 批量 MultiSend 实现解析)

2026-09-05 13:47:33作者:彭桢灵Jeremy

Drip 是 Union 仓库中实现 Cosmos 生态水龙头(Faucet)的独立 Rust 服务,同时支持多条链、每条链上多个 denom 的测试币发放,也是 Union 官方水龙头前端(app.union.build/faucet)的后端。本文基于 drip/README.md 的用法与 drip/src/main.rs 的源码实现,完整覆盖其启动命令、配置文件字段、GraphQL 请求协议、批量转账工作线程与 Cloudflare Turnstile 人机校验的实现细节,读完后可独立部署一个自己的多链 Faucet 并理解其防滥用与批量出块优化的内部机制。

1. 定位:一个“多链、多 denom”的 Cosmos Faucet

drip/README.md 对 Drip 的官方描述只有一句话:

Faucet for Cosmos chains. Supports multiple chains and multiple denoms per chains.

结合 drip/src/main.rs 的实现可以看到,Drip 的设计要点是:

  • 多链:配置文件 chains 是数组,每条链一个独立的工作线程轮询自己的数据库队列、连接自己的节点(见第 5 节 poll_loop);
  • 多 denom:每条链的 coins 也是数组,允许同一链上发放多种代币;
  • 异步队列:HTTP/GraphQL 请求只负责把发放请求写入 SQLite,真正广播交易由后台 worker 批量完成,从而把 N 笔转账合并进一笔 MsgMultiSend 交易,显著降低 gas 成本。

2. 快速开始:四终端演示(README 原始步骤)

README 给出的完整演示从仓库根目录执行,共四个 Tab(以下命令原样继承自 drip/README.md):

Tab 1,启动 Union Devnet:

nix run .#devnet-union -L

Tab 2,启动 Stargaze Devnet(可选,用于演示多链):

nix run .#devnet-stargaze -L

Tab 3,启动 Drip 服务:

nix run .#drip -- -c ./drip/config.json

Tab 4,发起发放请求:

cat ./drip/example-requests/union-devnet.json | http POST localhost:8000
cat ./drip/example-requests/stargaze-devnet.json | http POST localhost:8000

两点需要说明:

  1. 服务固定监听 0.0.0.0:8000(见 main.rsTcpListener::bind("0.0.0.0:8000")),GET / 打开 GraphiQL 交互界面,POST / 接收 GraphQL 请求。
  2. 示例请求文件实际上就是一份 GraphQL mutation 文档。以 drip/example-requests/union-devnet.json 为例:
{
  "query": "mutation UnoFaucetMutation($chain_id: String!, $denom: String!, $address: String!, $captchaToken: String!) { send(chainId: $chain_id, denom: $denom, address: $address, captchaToken: $captchaToken) }",
  "variables": {
    "chain_id": "union-devnet-1",
    "denom": "muno",
    "address": "union1m87a5scxnnk83wfwapxlufzm58qe2v65985exff70z95a2yr86yq7hl08h",
    "captchaToken": "helloworld"
  }
}

即通过 send(captchaToken, chainId, address, denom) 这个 mutation 申请一次发放,返回值为交易哈希字符串(失败时返回 "ERROR""ERROR: ratelimited")。注意示例中的 denom: "muno" 是旧值——当前 drip/config.json 中 Union Devnet 链配置的 denom 已是 au(仓库提交历史中存在 chore(devnet): muno -> au 的变更),而 send mutation 会校验 denom 必须存在于该链的 coins 列表中,因此实际请求时应与 config 中的 denom 保持一致,否则会得到 invalid denom 错误。

3. 命令行参数

drip/src/main.rs 使用 clap 定义了三个参数:

参数 短选项 默认值 说明
--config-file-path -c 必填 配置文件路径(JSON),README 中即为 ./drip/config.json
--batch-size -b 6000 单个链 worker 每轮从队列中取出并合并进一笔交易的最大请求数
--max-paginated-responses -m 50 各查询接口的单次最大返回条数上限

配置加载逻辑在 main.rs:读取文件后 serde_json::from_str::<Config> 解析,文件不存在或 JSON 非法时直接 panic 退出。

4. 配置文件全字段说明

配置结构由 main.rs 中的 ConfigChainCoinGasFillerConfig 等类型定义,仓库内的 drip/config.json 是双链示例。逐字段说明如下:

4.1 顶层 Config

字段 类型 默认 说明
chains Vec<Chain> 必填 支持发放的链列表
log_format text / json text 日志格式,分别对应 tracing 的文本/JSON 输出(见 main.rs
secret Option<String> 无(可选) Cloudflare Turnstile 的站点密钥,配置后所有请求必须携带有效的人机校验 token
bypass_secret Option<String> 无(可选) 后门绕过密钥:captchaToken 等于该值时跳过 Turnstile 校验,示例配置中为 "helloworld",对应示例请求里的 "captchaToken": "helloworld"
max_request_polls u32 必填(示例为 7) send mutation 等待交易哈希的最多轮询次数,每轮间隔 1 秒,超过后返回 "ERROR"
ratelimit_seconds u32 0 同一 (chain_id, denom, address) 三元组两次请求的最小间隔秒数,低于该间隔返回 "ERROR: ratelimited"

4.2 Chain 条目

示例配置中 Union Devnet 条目(节选自 drip/config.json):

{
  "id": "union-devnet-1",
  "bech32_prefix": "union",
  "memo": "drip drop greetings from union faucet",
  "ws_url": "ws://localhost:26657/websocket",
  "grpc_url": "http://localhost:9090",
  "gas_config": { "gas_price": "1.0", "gas_denom": "au", "gas_multiplier": "1.1", "max_gas": 40000000 },
  "signer": "0xaa820fa947beb242032a41b6dc9a8b9c37d8f5fbcda0966b1ec80335b10a7d6f",
  "coins": [ { "denom": "au", "amount": 13370 } ]
}

各字段含义:

  • id:链的唯一标识,必须与目标节点的 chain-id 完全一致。ChainClient::new 连接后会 assert_eq!(chain_id, chain.id) 强制校验(见 main.rs),不一致直接 panic;
  • bech32_prefix:如 unionstars,用于校验请求地址的 HRP 前缀(send mutation 中会 bech32 解码并比对);
  • memo:广播交易时附带的 memo 字符串;
  • 节点连接地址:示例配置中为 ws_url(CometBFT 节点 WebSocket RPC 端口 26657/26757)与 grpc_url(gRPC 端口 9090/9190)。需要留意的是,当前快照中 main.rsChain 结构体字段名为 rpc_url,与示例配置中的 ws_url/grpc_url 字段名并不一致,说明该仓库快照处于字段命名演进过程中;实际部署时应以当时可编译通过的代码结构为准,保证配置键与结构体字段匹配;
  • signer:发放账户的私钥(32 字节 hex)。私钥只存在服务端配置中,客户端只提交地址;
  • coins:该链可发放的 denom 与每次发放数量数组,支持同链多 denom;
  • gas_config:标签式枚举(tag = "type"),源码支持三种 gas 填充策略(见 main.rs):
    • Fixed(fixed::GasFiller):固定 gas price,示例配置中 gas_price/gas_denom/gas_multiplier/max_gas 属于这类固定价格配置;
    • Feemarket(FeemarketConfig):字段 max_gasgas_multiplier(可选)、denom(可选),查询链上 base fee 后按倍数加价;
    • OsmosisEip1559Feemarket(OsmosisEip1559FeemarketConfig):在 Feemarket 基础上增加 base_fee_multiplier,适配 Osmosis 的 EIP-1559 式费用市场。

4.3 运行时的数据库

Drip 启动时在进程工作目录创建 SQLite 数据库 db.sqlite3(WAL 模式),并建表 requests(见 main.rs):

CREATE TABLE IF NOT EXISTS requests (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    chain_id TEXT NOT NULL,
    denom TEXT NOT NULL,
    address TEXT NOT NULL,
    time TEXT,
    tx_hash TEXT
)

这是整个“请求-发放-回执”解耦的中枢:GraphQL 层写入 tx_hash IS NULL 的待处理行,worker 消费后回填交易哈希(失败时回填 ERROR: ... 文本)。

5. 请求链路:send mutation 的完整校验

send 实现于 main.rs,校验与处理顺序为:

  1. 链存在性chain_id 必须命中 chains 中的 id,否则 invalid chain_id
  2. denom 存在性:denom 必须在该链 coins 中,否则 invalid denom
  3. 人机校验:配置了 secret 时,调用 drip/src/turnstile.rsverify() 向 Cloudflare Turnstile 的 siteverify 端点提交 token 与 secret 做服务端验证;若 token 恰好等于 bypass_secretCaptchaBypassSecret)则直接放行,这就是示例请求能用 "helloworld" 通过的原因;
  4. 地址前缀校验:对 address 做 bech32 解码,HRP 必须等于该链 bech32_prefix
  5. 限流:查询该 (chain_id, denom, address) 最近一次请求时间,距今不足 ratelimit_seconds 秒则直接返回 "ERROR: ratelimited"
  6. 入队INSERT ... RETURNING id 写入请求(timedatetime('now'));
  7. 轮询回执:每秒查询该行的 tx_hash,最多 max_request_polls 次,拿到后返回哈希字符串,超时返回 "ERROR"

注意该接口是“同步等待 + 异步发放”的混合模式:客户端最长阻塞约 max_request_polls 秒。

6. 发放 worker:批量 MsgMultiSend 的核心实现

每条链启动一个 poll_loop 监督任务,内部不断 spawn 工作线程(见 main.rs),每轮循环:

  1. 取出该链待处理请求:SELECT id, denom, address FROM requests WHERE tx_hash IS NULL AND chain_id IS ?1 LIMIT ?2?2--batch-size
  2. 队列空则睡 1 秒重试;
  3. 调用 ChainClient::send(&requests) 广播,失败最多重试 5 次(attempt 递增记录日志);
  4. 成功后用 SQLite 的 array vtab 模块执行 UPDATE requests SET tx_hash = ?1 WHERE id IN rarray(?2),把同一批请求一次性回填为同一个交易哈希(交易哈希按 Cosmos SDK 惯例大写化后入库)。

send 方法(main.rs)是 gas 优化的关键:

  • 先按 denom 聚合(aggregate_by_denom),得到每种 denom 的总数量;
  • 构造一笔 bank/v1beta1.MsgMultiSendinputs 只有 1 个(faucet 账户,携带各 denom 的总量),outputs 为每个请求一个;
  • 通过 broadcast_tx_commit 同步等待上链并记录 gas_used

也就是说,同一批窗口内的 100 个地址、2 个 denom,最终只消耗 1 笔交易的 gas,这是 Drip 相比“每请求一交易”的朴素实现最显著的成本优势。

工作线程还做了容错设计:ChainClient 在每次 worker 重建时重新创建(注释说明为让 keyring 在 panic 后可重建),监督循环捕获 panic 后等 1 秒重启,保证单链故障不会拖垮进程。

7. 查询接口:转账历史

Query 对象提供三个分页查询(main.rs),均支持 limit(默认 10,上限为 --max-paginated-responses)与 offset_time 游标(默认当前时间,按 time < offset 倒序翻页):

查询 含义
handled_transfers 已成功发放的记录(tx_hash IS NOT NULL 且不以 ERROR 开头)
unhandled_transfers 仍在排队中的记录(tx_hash IS NULL
transfers_for_address 某地址的全部历史请求

返回结构为 Request { id, address, time, tx_hash }tx_hash 为可选(排队中为空)。这些查询是水龙头前端展示“发放中/已发放”状态的数据来源。

8. 部署:Nix 包与 NixOS 服务模块

drip/drip.nix 提供了两层部署能力:

  1. Nix 包crane.buildWorkspaceMember "drip" 构建二进制,即 README 中 nix run .#drip -- -c ./drip/config.json 的入口;
  2. NixOS 模块:开启 services.drip.enable 后生成 systemd 服务 drip,配置项为 packageconfig(attrs,会被序列化成 JSON 写入临时文件并以 -c 传入)、log-level(映射到 RUST_LOG 环境变量,服务 Restart = always)。

依赖方面,drip/Cargo.toml 显示 Drip 复用了 Union 工作区内的 cosmos-client(节点连接、gas 填充、本地签名钱包)、protos(bank/auth 的 protobuf 定义)、async-sqlite(启用 array/vtab 特性以支持 rarray 批量更新)与 async-graphql-axum

9. 关键文件索引

内容 路径
用法文档 drip/README.md
双链示例配置 drip/config.json
服务主程序(GraphQL、worker、批量发送) drip/src/main.rs
Turnstile 校验 drip/src/turnstile.rs
示例请求(Union / Stargaze) drip/example-requests/union-devnet.jsondrip/example-requests/stargaze-devnet.json
Nix 包与 NixOS 模块 drip/drip.nix

总结来说,Drip 用“GraphQL 入队 + SQLite 队列 + 每链批量 MsgMultiSend worker + Turnstile/限流双重防滥用”的架构,实现了一个可水平扩展到任意多条 Cosmos 链、且发放成本与请求数近似无关的水龙头服务;理解其 batch-sizemax_request_pollsratelimit_seconds 与三种 gas 填充配置,即可将其改造为任何 Cosmos 系项目的测试币发放基础设施。

登录后查看全文
热门项目推荐
相关项目推荐