使用有状态智能合约实现众筹应用
简介
设计概述
众筹应用创建一个基金,让特定资金接收人在指定日期之前能完成某个设定的目标。捐赠人可以在特定日期之前向该基金捐款。如果目标得以实现,接收人可以领取资金。如果没有完成目标,原始捐赠人可以收回捐款。
要在 Algorand 上创建这类应用,必须支持如下五个步骤。
- 创建基金 - 任何人都应能创建基金,设置开始,截止和关闭日期。同时需要设置创建人和接收人。只允许创建人的地址来修改或删除智能合约。
1a. 更新 - 要将托管帐户连接到有状态智能合约,首先需要获得创建有状态智能合约时返回的应用ID,然后对托管帐户代码做相应修改,添加返回的具体ID并进行编译,从而获得返回的托管地址。通过有状态智能合约的更新操作,该地址被置于全局状态。托管账户将保存所有捐款。
-
捐赠 - 只在接收捐款的有效期内接收捐赠。所有捐赠都由托管账户负责。
-
提取基金 - 如果基金的目标达到,基金的接收人应能在截止日期之后从托管账户中提取资金。
-
退回捐赠 - 如果基金没有达到目标,最初的捐赠人应被允许在截止日期后提回他们的捐款。
-
删除基金 - 在过了关闭时间节点后,基金应能被原始创建人删除。如果托管账户中还有剩余资金,则必须进行清算交易,将托管账户中的资金发送给收款人,比如无人认领的资金,包括未收回的捐款。
提示
该解决方案仅使用 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
,reclaim
或 Claim
参数。如果参数没有指定为其中一个,则智能合约将调用失败。
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
交易属性将托管账户中的所有资金结清给接收人。
第一笔交易必须是对有状态智能合约的调用,并带上字符串“ 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” 参数。第二笔交易是从托管账户到原始捐赠人的支付交易。付款交易的金额应为所捐赠金额减去交易费用,代管账户会支付此交易费用。
# 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
众筹应用的原始创建人应能够在基金关闭之后删除该应用。同时只有在托管帐户已经关闭的前提下,该基金才能被删除。如果托管帐户中仍然有资金,资金创建人可以将所有剩余资金发送给资金接收人,无论是否达到了募集目标。
如果托管帐户不为空,则用于处理删除操作的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 上找到。