创建文章

We are looking for publications that demonstrate building dApps or smart contracts!
See the full list of Gitcoin bounties that are eligible for rewards.

Solution Thumbnail

使用有状态智能合约实现众筹应用

简介

设计概述

众筹应用创建一个基金,让特定资金接收人在指定日期之前能完成某个设定的目标。捐赠人可以在特定日期之前向该基金捐款。如果目标得以实现,接收人可以领取资金。如果没有完成目标,原始捐赠人可以收回捐款。

要在 Algorand 上创建这类应用,必须支持如下五个步骤。

  1. 创建基金 - 任何人都应能创建基金,设置开始,截止和关闭日期。同时需要设置创建人和接收人。只允许创建人的地址来修改或删除智能合约。

1a. 更新 - 要将托管帐户连接到有状态智能合约,首先需要获得创建有状态智能合约时返回的应用ID,然后对托管帐户代码做相应修改,添加返回的具体ID并进行编译,从而获得返回的托管地址。通过有状态智能合约的更新操作,该地址被置于全局状态。托管账户将保存所有捐款。

  1. 捐赠 - 只在接收捐款的有效期内接收捐赠。所有捐赠都由托管账户负责。

  2. 提取基金 - 如果基金的目标达到,基金的接收人应能在截止日期之后从托管账户中提取资金。

  3. 退回捐赠 - 如果基金没有达到目标,最初的捐赠人应被允许在截止日期后提回他们的捐款。

  4. 删除基金 - 在过了关闭时间节点后,基金应能被原始创建人删除。如果托管账户中还有剩余资金,则必须进行清算交易,将托管账户中的资金发送给收款人,比如无人认领的资金,包括未收回的捐款。

1

2

提示

该解决方案仅使用 goal 命令行工具来对有状态应用进行调用。Algorand SDKs 也提供了相同的功能,可以替代 goal 工具。

托管账户

从上面的设计中,您可以看到通用的原子交易组是如何使用的。这些分组通常由对有状态智能合约的调用以及支付或资产交易组成。如果分组中的一个调用失败了,那么两个都将失败。作为无状态智能合约的托管账户,将会保存所有的捐款。有了托管帐户,任何帐户都可以将 Algos 或 Assets 发送到托管账户中,并且由相关逻辑来决定何时可以使用托管中的资金。只有在支付交易与对特定有状态智能合约的调用组成一个分组,并且该调用返回True,众筹应用程序才允许从托管账户中支出资金。在下面的代码中,应用程序ID设置为1,但应更改为从步骤1返回的应用程序ID ,如下所述。步骤 1-a 对如何在有状态智能合约中存储托管地址进行了说明。

#pragma version 2
// 先部署app,然后获取id
// 替换此TEAL中的id来创建托管地址
// 使用goal应用的更新操作来设置托管地址
// 该合约需要在这两笔交易属于同一分组的情况下才生效
global GroupSize
int 2
==

// 第一笔交易必须是一个ApplicationCall(或者叫:有状态智能合约)
gtxn 0 TypeEnum
int 6
==
&&

// 特定的 App ID 必须要被调用
// 应在创建后进行修改
gtxn 0 ApplicationID
int 1
==
&&

// 应用调用必须是通用的应用调用或者删除调用
//A general applicaiton call or a delete call
gtxn 0 OnCompletion
int NoOp
==

// 删除操作必须清空托管账户
int DeleteApplication
gtxn 0 OnCompletion
==
||
&&

// 验证两笔交易中是否包含rekey
gtxn 1 RekeyTo
global ZeroAddress
==
&&
gtxn 0 RekeyTo
global ZeroAddress
==
&&

以上代码被编译后,会产生一个可以接受资金的 Algorand 地址。

创建应用-步骤1

该 goal 命令行工具提供了一组与应用相关的命令来处理应用。goal app create 命令会创建应用。这是区块链的一个特定应用交易,与 Algorand Assets 的工作方式类似,它会返回一个应用ID。多个参数被传递给创建方法,这些参数主要与应用的存储空间使用量有关。在有状态智能合约中,可以将存储指定为全局存储或本地存储。全局存储表示应用本身可用的空间大小,本地存储是指应用中每个帐户可使用的空间大小。

众筹应用使用了8个全局变量(3个字节分片和5个整型)和1个本地存储变量(整型)。字节分片存储地址,全局整型变量存储日期、基金目标和当前基金总额的时间戳。本地整型变量存储具体特定用户捐赠的数量。

$ goal app create --creator {ACCOUNT} --approval-prog ./crowd_fund.teal --global-byteslices 3 --global-ints 5 --local-byteslices 0 --local-ints 1 --app-arg “int:begindatetimestamp” --app-arg "int:enddatetimespamp" --app-arg "int:1000000" --app-arg "addr:"{ACCOUNT} --app-arg "int:fundclosedatetimestamp" --clear-prog ./crowd_fund_close.teal -d ~/node/data

智能合约由创建时传递的两段程序组成。在 create 方法中还使用了一组参数来配置合约。这些参数设置了全局状态变量。在上面的示例中,传入了三个必需的参数:日期,基金募集目标和创建人地址。有关传递参数的更多详细信息,请参见将参数传递给有状态的应用。有关创建有状态智能合约的更多信息,请参见创建智能合约

有状态智能合约使用如下代码来处理智能合约的创建操作。这段代码处理关键全局变量的存储,并设置基金的接收人和应用的创建人。

// 批准程序
// 如果app id为0,则表示创建成功了
int 0
txn ApplicationID
==

// 如果没有创建则跳过此段
bz not_creation

// 保存创建人的地址,设置为全局状态
byte "Creator"
txn Sender
app_global_put

// 校验是否传递这5个参数
txn NumAppArgs
int 5
==
bz failed

// 保存开始日期
byte "StartDate"
txna ApplicationArgs 0
btoi
app_global_put

// 保存截止日期
byte "EndDate"
txna ApplicationArgs 1
btoi
app_global_put

// 保存资金目标
byte "Goal"
txna ApplicationArgs 2
btoi
app_global_put

// 保存资金接收人
byte "Receiver"
txna ApplicationArgs 3
app_global_put

// 设置Total总金额为零,然后保存
byte "Total"
int 0
app_global_put

//  保存资金关闭日期
byte "FundCloseDate"
txna ApplicationArgs 4
btoi
app_global_put

// 返回success
int 1
return
not_creation:

该 goal app create 命令将返回一个可用于调用有状态智能合约的应用ID。之后对该智能合约的所有调用都应使用此ID。

有关创建有状态智能合约的更多信息,请参阅创建智能合约

更新应用-步骤1a

警告

更新应用的交易可用于修改有状态智能合约的源代码,这可能是危险操作。如果这不是预期的行为,请包含在此类应用调用交易时会返回 failure 的相关代码。

在更新操作期间,将修改智能合约全局状态以添加托管帐户。该合约执行以下附加操作:

  • 验证有状态智能合约的创建人正在进行更新调用。
  • 验证是否传入了托管地址这个参数。
  • 将此托管地址存储在应用的全局状态中。

// 检查是否为更新操作 ---
int UpdateApplication
txn OnCompletion
==
bz not_update

// 校验是资金创建者在进行调用
byte "Creator"
app_global_get
txn Sender
==

// 调用应该传递托管账户地址
txn NumAppArgs
int 1
==
&&
bz failed

// 将地址保存在全局状态中
// 参数应为addr:
byte "Escrow"
txna ApplicationArgs 0
app_global_put
int 1
return
not_update:

goal 命令的示例如下:

$ goal app update --app-id={APPID} --from {ACCOUNT}  --approval-prog ./crowd_fund.teal   --clear-prog ./crowd_fund_close.teal --app-arg "addr:F4HJHVIPILZN3BISEVKXL4NSASZB4LRB25H4WCSEENSPCJ5DYW6CKUVZOA" 

请注意,合约的代码没有改变。更新操作将两个合约链接了起来。

有关更新有状态智能合约的更多信息,请参阅更新有状态智能合约

激活(opt-in)和捐赠-第2步

在有状态智能合约可以使用任何本地状态之前,帐户必须首先选择参与到应用的功能中来。请注意,如果有状态智能合约不使用任何本地状态,则不需要这样做。选择参与特定应用的 goal 命令如下所示。

$ goal app optin  --app-id {APPID}  --from {ACCOUNT} 

在众筹应用中,每个交易类型都会在代码中检查,以验证调用是否正确。开发人员文档解释了不同的交易类型。opt-in 调用恰好是本例中最后一个检查的调用。代码执行以下操作:

  • 验证已传递应用参数。
  • 如果没有参数,则假设帐户是刚加入到应用。
  • 检查交易类型以验证它是参与到应用的交易。

如果帐户在一次调用操作中进行了捐赠和参与,此代码将直接跳到 check_parms 标签,并将处理参数。

// 检查是否没有参数传入,仅当有人想选择参加时
// 注意此代码通过一个调用,来实现允许参与和捐赠

int 0
txn NumAppArgs
==
bz check_parms 

// 校验某人是否没有选择参加
int OptIn
txn OnCompletion
==
bz failed
int 1
return

check_parms:

check_params 代码处理捐赠、提取或返回资金。check_params 分支的代码基于传递到调用中的应用参数。智能合约仅支持donate,reclaimClaim参数。如果参数没有指定为其中一个,则智能合约将调用失败。

check_parms:
// 捐赠
txna ApplicationArgs 0
byte "donate"
==
bnz donate

// 返回
txna ApplicationArgs 0
byte "reclaim" 
==
bnz reclaim

// 提取
txna ApplicationArgs 0
byte "claim" 
==
bnz claim
b failed

对于捐赠,有状态智能合约执行以下操作:

  • 确保捐赠在基金的开始和结束日期之内。
  • 验证这是一个分组交易,第二笔交易付款给托管账户。
  • 递增并存储全部的总金额。
  • 将捐赠金额存储在捐赠人的本地存储空间。

donate:
// 校验日期是否在合法的范围
global LatestTimestamp
byte "StartDate"
app_global_get
>=

global LatestTimestamp
byte "EndDate"
app_global_get
<=
&&
bz failed

// 校验两笔交易为同一分组

global GroupSize
int 2
==

// 第二笔交易是支付交易
gtxn 1 TypeEnum
int 1
==
&&
bz failed

// 校验托管账户在接收第二笔付款交易
// second payment tx
byte "Escrow"
app_global_get
gtxn 1 Receiver
==
bz failed

// 增加总数
// 到目前位置募集的资金
byte "Total"
app_global_get
gtxn 1 Amount
+
store 1
byte "Total"
load 1
app_global_put

// 给捐赠的账户增加或设置捐赠金额

int 0 //sender
txn ApplicationID
byte "MyAmountGiven"
app_local_get_ex

// 检查是否为新捐赠者或退出的捐赠者
// 将值存储在捐赠者的本地存储空间中
bz new_giver
gtxn 1 Amount
+
store 3
int 0 // 发送人
byte "MyAmountGiven"
load 3
app_local_put
b finished

new_giver:
int 0 // 发送人
byte "MyAmountGiven"
gtxn 1 Amount
app_local_put
b finished

捐赠操作要求按两类交易进行分组。第一类交易是有状态的 TEAL 调用,第二类交易是对托管基金的付款交易。用于进行捐赠调用的 goal 命令如下所示。有状态的 TEAL 调用通过一个包含“donate”单词的字符串参数传递。

$ goal app call --app-id {APPID}  --app-arg "str:donate" --from={ACCOUNT}  --out=unsignedtransaction1.tx -d ~/node/data
$ goal clerk send --from={ACCOUNT} --to="F4HJHVIPILZN3BISEVKXL4NSASZB4LRB25H4WCSEENSPCJ5DYW6CKUVZOA" --amount=500000 --out=unsignedtransaction2.tx -d ~/node/data
$ cat unsignedtransaction1.tx unsignedtransaction2.tx > combinedtransactions.tx
$ goal clerk group -i combinedtransactions.tx -o groupedtransactions.tx 
$ goal clerk sign -i groupedtransactions.tx -o signout.tx
$ goal clerk rawsend -f signout.tx

有关对交易进行分组的更多信息,请参见原子转账

提取资金-步骤3

基金募集关闭后,如果达到了资金的目标,基金的接收人就可以领取该基金。为此,接收方必须从托管账户向其帐户提交一笔支付交易。该支付交易与领取资金的有状态 TEAL 应用调用组成一个分组。支付交易还应使用closeRemainderTo 交易属性将托管账户中的所有资金结清给接收人。

7

第一笔交易必须是对有状态智能合约的调用,并带上字符串“ claim”作为参数。第二笔交易应该是从托管 TEAL 程序到接收方的支付交易。参数 amount 值设置为0,并将 –close-to 属性值设置为基金的接收人。这将清空托管账户资金并转到接收人的帐户。

$ goal app call --app-id {APPID}  --app-arg "str:claim"  --from {ACCOUNT}  --out=unsignedtransaction1.tx -d ~/node/data
$ goal clerk send --to={ACCOUNT} --close-to={ACCOUNT} --from-program=./crowd_fund_escrow.teal --amount=0 --out=unsignedtransaction2.tx -d ~/node/data

cat unsignedtransaction1.tx unsignedtransaction2.tx > combinedtransactions.tx
$ goal clerk group -i combinedtransactions.tx -o groupedtransactions.tx 
$ goal clerk split -i groupedtransactions.tx -o split.tx 

$ goal} clerk sign -i split-0.tx -o signout-0.tx
cat signout-0.tx split-1.tx > signout.tx
$ goal clerk rawsend -f signout.tx

在上面的 goal 命令中,第二笔交易没有签名,因为它是无状态的TEAL合约,相关逻辑会处理对交易的签名。

有状态智能合约通过执行几项检查来处理此交易。

  • 该分组中有两笔交易。
  • 支付交易中的发送人是托管账户。
  • 支付交易的接收人是创建资金时设置的接收人。
  • 付款交易使用 CloseRemainderTo 来关闭托管账户。
  • 基金募集截止日期已过。
  • 资金的募集目标已经实现。

claim:
// 校验是否分组中有2笔交易
global GroupSize
int 2
==
bz failed

// 校验支付交易的接收方地址是创建资金时就存储的地址
gtxn 1 Receiver
byte "Receiver"
app_global_get
==

// 校验支付交易的发送方是托管账户
gtxn 1 Sender
byte "Escrow"
app_global_get
==
&&

// 校验 CloseRemainderTo 属性设置给了接收方

gtxn 1 CloseRemainderTo 
byte "Receiver"
app_global_get
==
&&

// 校验是否过了资金的截止日期
global LatestTimestamp
byte "EndDate"
app_global_get
>
&&
bz failed

// 校验目标是否达成
byte "Total"
app_global_get
byte "Goal"
app_global_get
>=
bz failed
b finished

退回资金-步骤4

如果未实现基金的目标,原始捐赠人需要收回他们的捐款。此操作需要一组两笔交易。第一笔交易是对有状态智能合约的调用,传递 “reclaim” 参数。第二笔交易是从托管账户到原始捐赠人的支付交易。付款交易的金额应为所捐赠金额减去交易费用,代管账户会支付此交易费用。

8

# app 账户为托管账户
$ goal app call --app-id {APPID} --app-account=F4HJHVIPILZN3BISEVKXL4NSASZB4LRB25H4WCSEENSPCJ5DYW6CKUVZOA --app-arg "str:reclaim" --from {ACCOUNT}  --out=unsignedtransaction1.tx
# 请注意,返还的捐款需要支付交易费,故金额与捐款金额不相等
$ goal clerk send --to={ACCOUNT} --close-to={ACCOUNT} --from-program=./crowd_fund_escrow.teal --amount=499000 --out=unsignedtransaction2.tx


$ cat unsignedtransaction1.tx unsignedtransaction2.tx > combinedtransactions.tx
$ goal clerk group -i combinedtransactions.tx -o groupedtransactions.tx 
$ goal clerk split -i groupedtransactions.tx -o split.tx 

$ goal clerk sign -i split-0.tx -o signout-0.tx
$ cat signout-0.tx split-1.tx > signout.tx
$ goal clerk rawsend -f signout.tx

在上面的 goal 命令中,第二笔交易没有签名,因为它是无状态的TEAL合约,相关逻辑会处理对交易的签名。

有状态智能合约通过执行多次检查和写入来处理此交易。这是一个更复杂的操作,因为捐赠人不是必须收回其全部捐款,而且实际上他们也许决定永远不收回他们的捐款。步骤5处理这种情况。另外,对于最后一个收回捐款的人,相应的付款交易必须使用 CloseRemainderTo 属性来关闭托管账户。这部分智能合约会做如下检查。

  • 该分组中有两笔交易。
  • 智能合约调用者是支付交易的接收人。
  • 支付交易的发送者是托管账户。
  • 基金的截止日期已过。
  • 基金目标未实现。
  • 验证支付交易金额与交易费用相加后的金额,是否等于或小于原始捐款金额。
  • 检查这是否为最后一笔要退回的捐款。如果是,请确认已设置 CloseRemainderTo 参数,以将托管账户关闭。

reclaim:
// 校验分组内是否有2笔交易
global GroupSize
int 2
==
bz failed

// 校验智能合约调用者是支付交易的接收人
gtxn 1 Receiver
gtxn 0 Sender
==

// 校验支付交易来自托管账户
gtxn 1 Sender
byte "Escrow"
app_global_get
==
&&

// 校验资金的截止日期已过
global LatestTimestamp
byte "EndDate"
app_global_get
>
&&

// 校验资金的目标没有达成
byte "Total"
app_global_get
byte "Goal"
app_global_get
<
&&

// 因为托管账户需要支付交易费,此交易费应小于等于原始账户金额的大小
gtxn 1 Amount
gtxn 1 Fee
+
int 0
byte "MyAmountGiven"
app_local_get
<=
&&
bz failed

// 检查托管账户总金额
// 托管账户对应的 --app-account 
// 需要传递托管账户的地址
// 检查是否为最后返还的捐款,如果是则需要设置 closeremainderto 参数
gtxn 1 Fee
gtxn 1 Amount
+
// 整数1是指托管账户
int 1
balance
==
gtxn 1 CloseRemainderTo 
global ZeroAddress
==
||
bz failed

// 减少发送方的给定金额
int 0
byte "MyAmountGiven"
app_local_get
gtxn 1 Amount
-
gtxn 1 Fee
-
store 5
int 0
byte "MyAmountGiven"
load 5
app_local_put
b finished

删除应用-步骤5

众筹应用的原始创建人应能够在基金关闭之后删除该应用。同时只有在托管帐户已经关闭的前提下,该基金才能被删除。如果托管帐户中仍然有资金,资金创建人可以将所有剩余资金发送给资金接收人,无论是否达到了募集目标。

9

如果托管帐户不为空,则用于处理删除操作的goal命令需要进行两笔交易。第一笔是有状态智能合约的删除应用交易,另一笔是从托管账户到资金接收人的支付交易。交易的金额应为0,并且CloseRemainderTo 属性应设置为资金接收人。

# 将托管账户值传递给账户数组来检查它是否为空
$ goal app delete --app-id {APPID}  --from {ACCOUNT}  --app-account=F4HJHVIPILZN3BISEVKXL4NSASZB4LRB25H4WCSEENSPCJ5DYW6CKUVZOA --out=unsignedtransaction1.tx
$ goal clerk send --from-program=./crowd_fund_escrow.teal --to={ACCOUNT} --amount=0 -c {ACCOUNT} --out=unsignedtransaction2.tx


$ cat unsignedtransaction1.tx unsignedtransaction2.tx > combinedtransactions.tx
$ goal clerk group -i combinedtransactions.tx -o groupedtransactions.tx 
$ goal clerk split -i groupedtransactions.tx -o split.tx 

$ goal clerk sign -i split-0.tx -o signout-0.tx
$ cat signout-0.tx split-1.tx > signout.tx
$ goal clerk rawsend -f signout.tx

任何帐户都可以删除智能合约,即使该帐户没有创建这个应用,这可能造成非预期的结果,因此需要通过代码阻止此类操作。众筹应用应只允许创建人来删除智能合约。

智能合约执行以下操作:

· 验证这是一个删除应用的调用。

· 确保是创建人在尝试删除该应用。

· 检查当前是否已过基金关闭日期。

· 如果托管账户资金为空,则允许删除操作。

· 如果托管不为空,请验证这是具有两笔交易的原子转账操作。

· 验证第二笔交易是付款交易。

· 验证第二笔交易是否将 CloseRemainderTo 设置为接收方以关闭托管账户。

· 验证第二笔交易的金额是否设置为0。

· 验证托管帐户是否为第二笔交易的发送方。

// 检查是否是删除应用的交易
int DeleteApplication
txn OnCompletion
==
bz not_deletion

// 要删除此应用,创建者必须清空托管账户,如果有剩余资金需要发送给接收人
// 只有创建人才能删除此应用
byte "Creator"
app_global_get
txn Sender
==

// 检查是否过了关闭日期
global LatestTimestamp
byte "FundCloseDate"
app_global_get
>=
&&
bz failed

// 如果托管账户金额为零,可删除应用,托管金额须作为参数传递
int 0
int 1
balance
==

// 如果balance为零,允许删除
bnz finished
// 如果托管账户金额不为零,则必须有一组两笔交易
global GroupSize
int 2
==

// 第二笔交易是支付交易
gtxn 1 TypeEnum
int 1
==
&&

// 第二笔支付交易为与资金接收人间的清算交易
byte "Receiver"
app_global_get
gtxn 1 CloseRemainderTo
==
&&

// 支付交易的金额应为零
gtxn 1 Amount
int 0
==
&&

// 支付交易的发送方应为托管账户
byte "Escrow"
app_global_get
gtxn 1 Sender
==
&&
bz failed
int 1
return

结论

本众筹应用演示了如何使用 Algorand 丰富的 Layer 1 功能来实现一个完整的应用。该应用的完整源代码可在Github 上找到。