☰
Vyper 众筹合约实战:Goal 达成、退款与资金流转的 Crowdfund 合约逐行解析
2026/10/13 2:41:36 网站建设 项目流程
  • 编程语言
  • 编译器
  • 区块链

【免费下载链接】vyper

Pythonic Smart Contract Language for the EVM

项目地址:https://gitcode.com/gh_mirrors/vy/vyper
点击查看免费下载

本篇基于 Vyper 官方教程vyper-by-example章节中的 Crowdfund 案例,完整讲解一个"达标付款给受益人、未达标全额退款"的众筹合约设计:包括状态变量与public可见性语义、@payable参与方法、finalize()结算与refund()提现模式,并结合仓库源码印证send()内建函数的底层实现和self.balance内置属性的编译方式,以及用仓库中的功能测试复现成功/失败两条资金路径。读完后你可以独立写出并验证一个完整的众筹合约。

一、案例背景与业务规则

该案例位于官方文档 docs/vyper-by-example/crowdfund.rst(在 vyper-by-example 索引 中归入 "Escrow & Crowdfunding" 分类),对应的可编译示例源码是 examples/crowdfund.vy。

业务规则非常清晰:

  • 参与者(funder)在众筹期间调用participate()往合约里打款;
  • 若众筹截止时合约余额 ≥ 预设目标goal,任何人可调用finalize(),把全部资金转给受益人beneficiary;
  • 若众筹截止时余额 <goal,参与者可调用refund()取回自己的全部贡献。

文档开篇即给出警告:

This is example code for learning purposes. Do not use in production without thorough review and testing.

即这是教学代码,生产环境使用前必须自行完整审查和测试。

二、完整合约源码

合约声明#pragma version >0.3.10,完整代码见 examples/crowdfund.vy:

funders: HashMap[address, uint256] beneficiary: address deadline: public(uint256) goal: public(uint256) timelimit: public(uint256) finalized: bool # Setup global variables @deploy def __init__(_beneficiary: address, _goal: uint256, _timelimit: uint256): self.beneficiary = _beneficiary self.deadline = block.timestamp + _timelimit self.timelimit = _timelimit assert _goal > 0, "Goal must be non-zero" self.goal = _goal # Participate in this crowdfunding campaign @external @payable def participate(): assert block.timestamp < self.deadline, "deadline has expired" assert not self.finalized self.funders[msg.sender] += msg.value # Enough money was raised! Send funds to the beneficiary @external def finalize(): assert block.timestamp >= self.deadline, "deadline has not expired yet" assert self.balance >= self.goal, "goal has not been reached" assert self.balance > 0 self.finalized = True send(self.beneficiary, self.balance) # Let participants withdraw their fund @external def refund(): assert block.timestamp >= self.deadline and self.balance < self.goal assert not self.finalized assert self.funders[msg.sender] > 0 value: uint256 = self.funders[msg.sender] self.funders[msg.sender] = 0 send(msg.sender, value)

下面按文档的讲解顺序逐段展开。

三、状态变量:HashMap 记账与public可见性

funders: HashMap[address, uint256] beneficiary: address deadline: public(uint256) goal: public(uint256) timelimit: public(uint256) finalized: bool

各变量职责:

  • funders:以地址为键、以uint256为值的映射,记录每个参与者的累计贡献额(单位 wei)。用映射而不是列表,是因为参与者是动态加入的,且退款时按地址查账;
  • beneficiary:众筹成功后资金的最终接收者;
  • deadline/timelimit:众筹的绝对截止时刻(时间戳)与持续时长,二者共同确定众筹窗口;
  • goal:众筹目标金额(wei),达标判断的依据;
  • finalized:布尔标志,防止重复结算。

可见性规则(文档原话的含义):

  • 像deadline、goal、timelimit这样声明为public(uint256)的变量,编译器会自动为其生成一个同名的外部 getter 函数,链下或跨合约都可以直接调用读取;
  • Vyper 没有对应的private()装饰器——变量不加public()时默认就是私有的,只能合约内部访问。这是与"有 public 就有 private"的直觉相反的一个设计点,官方文档专门用 note 强调了这一点。

这也解释了为什么 tests/functional/examples/crowdfund/test_crowdfund_example.py 里测试可以直接写c.deadline()、c.goal()、c.timelimit()这样的调用——它们就是public变量自动生成的 getter。

四、构造函数:@deploy与截止时刻的确定

@deploy def __init__(_beneficiary: address, _goal: uint256, _timelimit: uint256): self.beneficiary = _beneficiary self.deadline = block.timestamp + _timelimit self.timelimit = _timelimit assert _goal > 0, "Goal must be non-zero" self.goal = _goal

构造函数接收三个参数:受益人地址、以 wei 为单位的众筹目标、众筹时长(秒)。要点:

  • 使用@deploy装饰器标记构造函数(@deploy def __init__是当前版本的标准写法),合约部署时执行一次;
  • self.deadline = block.timestamp + _timelimit是核心设计:把相对时长_timelimit在部署瞬间锚定为绝对时间戳,之后participate()、finalize()、refund()的准入判断都基于这个确定值,而不是每次都重新算相对时间;
  • assert _goal > 0, "Goal must be non-zero"在部署阶段就拦截非法参数——目标为零的合约没有任何意义,提前失败比链上运行期失败更安全。

测试中的部署调用印证了参数含义:

# tests/functional/examples/crowdfund/test_crowdfund_example.py return get_contract(contract_code, *[env.accounts[1], 50, 60])

即受益人为accounts[1]、目标 50(wei)、时长 60(秒)。部署后的第一个断言:

assert c.timelimit() == 60 assert c.deadline() - env.timestamp == 59 # 部署块已消耗 1 个时间单位

确认了deadline = 部署时间戳 + timelimit的计算逻辑。

五、participate():@payable打款与映射记账

@external @payable def participate(): assert block.timestamp < self.deadline, "deadline has expired" assert not self.finalized self.funders[msg.sender] += msg.value
  • @payable装饰器允许调用者在调用本方法时附带发送 ETH;没有@payable的外部函数收到msg.value > 0会直接失败;
  • msg.sender是发起本次调用的地址,msg.value是本次随调用打来的 wei 数额,二者都是与block类似的链上内置变量,在任何 Vyper 合约中都可用;
  • self.funders[msg.sender] += msg.value一行完成记账:同一地址多次参与会自动累加,天然支持"一人多次打款";
  • 两条assert是准入护栏:截止时间前才允许参与(block.timestamp < self.deadline),且合约不能被标记为已结算。Vyper 的assert附带字符串消息,条件不满足时整笔交易回滚并携带该 revert 消息,方便链下定位原因。

六、finalize():达标结算与self.balance、send()

@external def finalize(): assert block.timestamp >= self.deadline, "deadline has not expired yet" assert self.balance >= self.goal, "goal has not been reached" assert self.balance > 0 self.finalized = True send(self.beneficiary, self.balance)

方法逻辑:

  1. 必须已过截止时间;
  2. 合约余额self.balance必须 ≥goal;
  3. 置位finalized = True,之后任何人再调用participate()都会因assert not self.finalized失败;
  4. 通过send()把合约内全部余额转给beneficiary——注意是"整锅端",这也是该简化示例的一个特点(见第八节安全讨论)。

文档在此处特别指出:self.balance是一个从未被显式赋值、却始终可访问的内置变量。它在编译器中的来源可以确认:

  • 语义层:address类型的成员属性在 vyper/semantics/types/primitives.py#L414-L423 中定义为AddressT._type_members = {"balance": UINT(256), "codehash": ..., "codesize": ..., ...}。self本身就是合约的 address,所以self.balance被识别为uint256类型的属性访问;
  • 代码生成层:在 vyper/codegen_venom/expr.py#L792-L794 中,attr == "balance"的属性访问被直接降阶为builder.balance(addr)指令,最终对应 EVM 的BALANCE操作码。因此self.balance读取的是当前区块里合约地址的实时余额,而不是合约自己维护的计数器——参与者每打一笔msg.value,它就会自动增长。

send()的底层实现

send()是 Vyper 内建函数,签名与行为见 docs/built-in-functions.rst#L285-L307。其编译实现在 vyper/builtins/functions.py#L1226-L1238:

class Send(BuiltinFunctionT): _id = "send" _inputs = [("to", AddressT()), ("value", UINT256_T)] # default gas stipend is 0 _kwargs = {"gas": KwargSettings(UINT256_T, 0)} @process_inputs def build_IR(self, expr, args, kwargs, context): to, value = args gas = kwargs["gas"] ... send_op = ensure_eval_once("send_builtin", ["call", gas, to, value, 0, 0, 0, 0]) return IRnode.from_list(["assert", send_op], error_msg="send failed")

两个关键点:

  • send底层就是一次CALL,且第三参数(gas 额度)默认是0——即send(to, value)不显式转发 gas。文档同时提醒:向非空地址转账非零 ETH 时,EVM 会自动附加 2300 gas 的 stipend;但如果value == 0,连这个 stipend 也没有,此时若接收方有代码就必然失败。本合约里finalize()与refund()转的都是真实正数余额,正常账户都能成功;
  • IR 层包了一层assert ... "send failed":转账失败会让整笔交易 revert。对finalize()而言这是保护(转不出钱就不结算),对refund()而言则意味着受益人/收款地址是普通 EOA 时才可靠。

七、refund():提现模式(withdraw pattern)

@external def refund(): assert block.timestamp >= self.deadline and self.balance < self.goal assert not self.finalized assert self.funders[msg.sender] > 0 value: uint256 = self.funders[msg.sender] self.funders[msg.sender] = 0 send(msg.sender, value)

退款条件与finalize()严格互补:

条件finalize()refund()
时间block.timestamp >= self.deadline同左(且二者都要求已过截止)
余额self.balance >= self.goalself.balance < self.goal
状态设置finalized = True要求not self.finalized

即两个方法不可能同时通过准入——余额要么达标要么不达标,finalized标志又阻止了退款窗口被二次利用。这保证众筹只能走一条终局:成功付款或失败退款,不会既付给受益人又退给参与者。

方法体采用经典的先置零、后转账(check-effects-interaction 的简化形态,也是 withdraw pattern 的常见写法):

  1. assert self.funders[msg.sender] > 0——没贡献的人不能退款(测试里a0调用refund即因该 assert 失败);
  2. 把额度读进局部变量value,先把存储里的份额清零;
  3. 再send(msg.sender, value)。

先清零再转账的意义在于:即使接收地址是一个会 reenter(重入调用)的恶意合约,二次调用refund()时第 3 行 assert 也会因份额已为 0 而失败,用户不会"重复提现"。

仓库测试完整覆盖了这段逻辑(见下节)。

八、测试验证:成功与退款两条路径

测试文件 tests/functional/examples/crowdfund/test_crowdfund_example.py 基于get_contractfixture(定义见 tests/conftest.py#L290)编译部署同一份examples/crowdfund.vy,并驱动模拟链时间(env.timestamp += N)。

成功路径test_crowdfund_example:

c.participate(value=5) # a0 先投 5 c.participate(value=49) # 再投 49,合计 54 >= goal 50 env.timestamp += 100 # 跨过 deadline c.finalize() assert post_bal - pre_bal == 54 # 受益人 a1 收到全部 54 wei

退款路径test_crowdfund_example2:

c.participate(value=1, sender=a3) c.participate(value=2, sender=a4) c.participate(value=3, sender=a5) c.participate(value=4, sender=a6) env.timestamp += 100 with tx_failed(): c.refund(sender=a0) # a0 无贡献 -> assert 失败 c.refund(sender=a3) with tx_failed(): c.refund(sender=a3) # 份额已清零 -> 二次退款失败 c.refund(sender=a4) c.refund(sender=a5) c.refund(sender=a6) assert [y - x for x, y in zip(pre_bals, post_bals)] == [1, 2, 3, 4] # 各拿回自己的原额

这组断言精确验证了第七节说的三件事:无贡献者退款失败、退款只能发生一次、退款金额恰好等于各自映射中记录的累计贡献。

九、设计取舍与安全提醒

结合文档警告与源码结构,对这个教学示例做如下评估:

  • 示例定位明确:文件头注释THIS IS EXAMPLE CODE, NOT MEANT TO BE USED IN PRODUCTION! CAVEAT EMPTOR!与文档 warning 一致,以下讨论均基于"教学"前提;
  • 无权限控制:finalize()与refund()任何地址都可调用。这是刻意为之(避免单点信任),但也意味着截止后任何人都能触发结算;
  • send的 0-gas 调用:如第六节源码所示,默认不转发 gas。若beneficiary是合约,其receive路径需要执行超过 stipend 的逻辑时转账会失败、finalize()整体回滚。生产化时通常改用raw_call或显式gas参数处理;
  • assert self.balance > 0的兜底:在 EVM 语义下若goal >= 1且上一行balance >= goal成立则第三行必然成立,它是防御性冗余,保证不会以 0 值调用send(0 值调用不享受 2300 gas stipend,接收方为合约时必然失败);
  • 时间依赖:合约完全依赖block.timestamp判定窗口,其值由出块方给出、存在一定可控性,这是链上时间锁的通用局限,教学场景可接受;
  • 扩展方向:该合约没有事件日志(event)、没有多币种抽象、没有中途停止机制。若要工程化,可参考同目录的 docs/vyper-by-example/safe-remote-purchase.rst(同属 Escrow 类案例)与 docs/vyper-by-example/blind-auction.rst 中更完整的状态机写法。

十、小结

Crowdfund 示例虽然只有 50 余行,却覆盖了 Vyper 的几个高频特性:HashMap[address, uint256]按地址记账、public变量自动 getter、@deploy def __init__构造函数、@payable+msg.sender/msg.value收款、self.balance内置属性、send()内建转账,以及"达标结算 / 失败退款"的二选一终局设计与先置零后转账的退款写法。配合 examples/crowdfund.vy、tests/functional/examples/crowdfund/test_crowdfund_example.py 与 vyper/builtins/functions.py 中的Send实现,这套材料足以作为编写链上资金类合约的起点模板。

  • 编程语言
  • 编译器
  • 区块链

【免费下载链接】vyper

Pythonic Smart Contract Language for the EVM

项目地址:https://gitcode.com/gh_mirrors/vy/vyper
点击查看免费下载

相关推荐

上一篇:Display Driver Uninstaller (DDU):5步精通显卡驱动深度清理终极秘籍
下一篇:手柄玩转PC游戏:AntiMicroX让你的旧手柄重获新生 🎮

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询