原英文文档: https://book.clarity-lang.org/ch08-01-time-locked-wallet.html
时间锁钱包
随着时间的推移,区块高度可用于执行操作。如果您知道平均出块时间,那么您可以大致计算出在特定时间范围内将开采多少区块。我们将使用这个概念来创建一个在特定区块高度解锁的钱包合约程序。如果您想在特定时间段后将代币授予某人,这样的合约程序会很有用。想象一下,在未来,您想为孩子长大后留一些钱。现在,你可以通过智能合约程序做到这一点!让我们开始吧。
从我们的主 projects 文件夹中,我们创建一个新项目。
clarinet new timelocked-wallet
在 timelocked-wallet 文件夹中,我们使用以下命令创建合约程序文件:
clarinet contract new timelocked-wallet
功能
不要立即开始写代码,让我们花点时间考虑一下我们想要拥有的功能。
用户可以部署时间锁钱包合约。
然后,用户指定钱包解锁的区块高度和受益人。
任何人,不仅仅是合约程序的部署者,都可以向合约程序发送代币。
一旦达到指定的区块高度,受益人就可以领取代币。
此外,受益人可以将领取钱包的权利转让给不同的用户。 (无论出于何种原因。)
考虑到上述情况,合约程序将具有以下公有函数:
lock,确定受益人,解锁高度和初始存款金额。
claim,当且仅当达到解锁高度并且 tx-sender 等于受益人时,才将代币转移给 tx-sender 。
bestow,允许受益人转移领取钱包的权利。
常量和变量
合约程序应该尽可能易于阅读和维护。因此,我们将大量使用常量来定义合约所有者和各种错误状态。错误可以采用以下形式:
合约程序所有者以外的其他人调用 lock 函数。
合约程序的所有者多次尝试调用 lock 函数。
解锁的区块高度是以前的区块高度,区块高度在链上已经产生。
合约程序的所有者以零 (u0) 的初始存款调用 lock 函数。
受益人以外的其他人调用 claim 或者 lock 函数。
受益人调用 claim 函数,但尚未达到区块解锁高度。
需要两个数据变量将受益人和解锁高度存储为无符号整数。为合约程序的未初始化状态,我们将使受益人成为可选的主体用户类型。 (也就是说,在合约程序的所有者调用 lock 之前。)
;; Owner
(define-constant contract-owner tx-sender)
;; Errors
(define-constant err-owner-only (err u100))
(define-constant err-already-locked (err u101))
(define-constant err-unlock-in-past (err u102))
(define-constant err-no-value (err u103))
(define-constant err-beneficiary-only (err u104))
(define-constant err-unlock-height-not-reached (err u105))
;; Data
(define-data-var beneficiary (optional principal) none)
(define-data-var unlock-height uint u0)
错误代码本身是组成的。意味着由我们合同程序的前端应用程序处理。只要我们使用 (err ...) 响应类型,我们就确信任何可能的更改都会恢复。
执行 lock
lock 函数只不过是将一些代币从 tx-sender 发送到自身并设置两个变量。但是,我们不能忘记检查是否设置了适当的条件。具体来说:
只有合约程序的所有者可以调用 lock 。
钱包不能被锁定两次。
解锁高度应该在未来的某个时间点;也就是说,它必须大于当前高度。
初始存款应大于零。这样操作,存款应该成功。
其中大部分转化为断言。因此,该功能实现如下:
(define-public (lock (new-beneficiary principal) (unlock-at uint) (amount uint))
(begin
(asserts! (is-eq tx-sender contract-owner) err-owner-only)
(asserts! (is-none (var-get beneficiary)) err-already-locked)
(asserts! (> unlock-at block-height) err-unlock-in-past)
(asserts! (> amount u0) err-no-value)
(try! (stx-transfer? amount tx-sender (as-contract tx-sender)))
(var-set beneficiary (some new-beneficiary))
(var-set unlock-height unlock-at)
(ok true)
)
)
请注意,我们如何使用我们之前定义的常量作为断言的抛出值?这允许一些非常清晰的代码。 (as-contract tx-sender) 部分为我们提供了合约程序的主体用户。
执行 bestow 函数
bestow 函数将很简单。它将检查 tx-sender 是否是当前受益人,如果是,则将受益人更新为通过的主体用户。需要记住的一个是主体用户被存储为(optional principal)。 因此,在进行比较之前,我们需要将 tx-sender 包装在 (some ...) 中。
(define-public (bestow (new-beneficiary principal))
(begin
(asserts! (is-eq (some tx-sender) (var-get beneficiary)) err-beneficiary-only)
(var-set beneficiary (some new-beneficiary))
(ok true)
)
)
执行 claim 函数
最后,claim 函数应该检查 tx-sender 是否是受益人,以及是否已达到解锁高度。
(define-public (claim)
(begin
(asserts! (is-eq (some tx-sender) (var-get beneficiary)) err-beneficiary-only)
(asserts! (>= block-height (var-get unlock-height)) err-unlock-height-not-reached)
(as-contract (stx-transfer? (stx-get-balance tx-sender) tx-sender (unwrap-panic (var-get beneficiary))))
)
)
人工测试
是时候进入 clarinet console 来测试合约程序了。
Contracts
+-------------------------------------------------------------+--------------------------------------+
| Contract identifier | Public functions |
+-------------------------------------------------------------+--------------------------------------+
| ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.timelocked-wallet | (bestow (new-beneficiary principal)) |
| | (claim) |
| | (lock |
| | (new-beneficiary principal) |
| | (unlock-at uint) |
| | (amount uint)) |
+-------------------------------------------------------------+--------------------------------------+
如果合约程序没有出现,则说明存在错误或语法错误。我们要使用 clarinet check 来追踪它们。
对于第一个测试,钱包将被部署者 (wallet_1) 之后的第一个主体用户锁定。我们可以选择一个非常低的区块高度,因为控制台会话总是从区块高度零开始。控制台交互将钱包锁定到高度 10,初始存款为 100 mSTX:
>> (contract-call? .timelocked-wallet lock 'ST1J4G6RR643BCG8G8SR6M2D9Z9KXT2NJDRK3FBTK u10 u100)
Events emitted
{"type":"stx_transfer_event","stx_transfer_event":{"sender":"ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE","recipient":"ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.timelocked-wallet","amount":"100"}}
执行有效!我们要密切关注从 tx-sender 到合约程序的 STX 转移事件。可以使用管理命令 ::get_assets_maps 来验证合约程序的余额。
>> ::get_assets_maps
+-------------------------------------------------------------+---------+
| Address | STX |
+-------------------------------------------------------------+---------+
| ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE (deployer) | 999900 |
+-------------------------------------------------------------+---------+
| ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.timelocked-wallet | 100 |
+-------------------------------------------------------------+---------+
然后我们假设受益人的身份,看看我们是否可以认领钱包。 (请记住,在这种情况下必须指定完整的合约程序主体用户。)
>> ::set_tx_sender ST1J4G6RR643BCG8G8SR6M2D9Z9KXT2NJDRK3FBTK
tx-sender switched to ST1J4G6RR643BCG8G8SR6M2D9Z9KXT2NJDRK3FBTK
>> (contract-call? 'ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.timelocked-wallet claim)
(err u105)
尝试声明返回一个(错误 u105 )。这是未达到解锁高度相关的错误值。到现在为止还挺好。
REPL 中的块高度不会自行增加。可以使用 ::advance_chain_tip 模拟挖矿。让我们看看在将区块高度增加 10 后是否可以领取钱包。
>> ::advance_chain_tip 10
10 blocks simulated, new height: 10
>> (contract-call? 'ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.timelocked-wallet claim)
Events emitted
{"type":"stx_transfer_event","stx_transfer_event":{"sender":"ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.timelocked-wallet","recipient":"ST1J4G6RR643BCG8G8SR6M2D9Z9KXT2NJDRK3FBTK","amount":"100"}}
(ok true)
ok 和 STX 转账事件证明它有效。你还可以检查资产地图以获得良好的衡量标准。
单元测试
我们确定以下情况以编写全面的单元测试。智能合约程序:
允许合约程序的所有者锁定金额。
不允许其他人锁定金额。
不能多次锁定。
无法将解锁高度设置为小于当前区块高度的值。
允许受益人将认领权授予其他人。
不允许其他任何人将索赔权转赠给其他人。 (甚至不是合约程序的所有者。)
允许受益人在达到区块高度时要求余额。
不允许受益人在达到区块高度之前要求余额。
一旦达到区块高度,除了受益人之外,没有人可以认领钱包余额。
Clarinet 具有内置的 assertion 断言函数,可检查预期的 STX 转账事件是否确实发生。这些将用于保持单元测试简洁。
合约程序的测试文件始终位于 tests 文件夹中。它以合约命名:timelocked-wallet_test.ts 。清除文件,要确保将 import 语句保留在顶部。
测试 lock
我们首先编写涵盖不同 lock 情况的四个测试。
Clarinet.test({
name: "Allows the contract owner to lock an amount",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const amount = 10;
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(10), types.uint(amount)], deployer.address)
]);
// The lock should be successful.
block.receipts[0].result.expectOk().expectBool(true);
// There should be a STX transfer of the amount specified.
block.receipts[0].events.expectSTXTransferEvent(amount, deployer.address, `${deployer.address}.timelocked-wallet`);
}
});
Clarinet.test({
name: "Does not allow anyone else to lock an amount",
async fn(chain: Chain, accounts: Map<string, Account>) {
const accountA = accounts.get('wallet_1')!;
const beneficiary = accounts.get('wallet_2')!;
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(10), types.uint(10)], accountA.address)
]);
// Should return err-owner-only (err u100).
block.receipts[0].result.expectErr().expectUint(100);
}
});
Clarinet.test({
name: "Cannot lock more than once",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const amount = 10;
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(10), types.uint(amount)], deployer.address),
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(10), types.uint(amount)], deployer.address)
]);
// The first lock worked and STX were transferred.
block.receipts[0].result.expectOk().expectBool(true);
block.receipts[0].events.expectSTXTransferEvent(amount, deployer.address, `${deployer.address}.timelocked-wallet`);
// The second lock fails with err-already-locked (err u101).
block.receipts[1].result.expectErr().expectUint(101);
// Assert there are no transfer events.
assertEquals(block.receipts[1].events.length, 0);
}
});
Clarinet.test({
name: "Unlock height cannot be in the past",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const targetBlockHeight = 10;
const amount = 10;
// Advance the chain until the unlock height plus one.
chain.mineEmptyBlockUntil(targetBlockHeight + 1);
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(targetBlockHeight), types.uint(amount)], deployer.address),
]);
// The second lock fails with err-unlock-in-past (err u102).
block.receipts[0].result.expectErr().expectUint(102);
// Assert there are no transfer events.
assertEquals(block.receipts[0].events.length, 0);
}
});
测试 bestow
bestow 是一个简单的函数,允许受益人转让认领权。因此,我们必须确保只有受益人才能成功调用 bestow 。
Clarinet.test({
name: "Allows the beneficiary to bestow the right to claim to someone else",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const newBeneficiary = accounts.get('wallet_2')!;
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(10), types.uint(10)], deployer.address),
Tx.contractCall('timelocked-wallet', 'bestow', [types.principal(newBeneficiary.address)], beneficiary.address)
]);
// Both results are (ok true).
block.receipts.map(({ result }) => result.expectOk().expectBool(true));
}
});
Clarinet.test({
name: "Does not allow anyone else to bestow the right to claim to someone else (not even the contract owner)",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const accountA = accounts.get('wallet_3')!;
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(10), types.uint(10)], deployer.address),
Tx.contractCall('timelocked-wallet', 'bestow', [types.principal(deployer.address)], deployer.address),
Tx.contractCall('timelocked-wallet', 'bestow', [types.principal(accountA.address)], accountA.address)
]);
// All but the first call fails with err-beneficiary-only (err u104).
block.receipts.slice(1).map(({ result }) => result.expectErr().expectUint(104));
}
});
测试 claim
对于 claim,我们会测试是否达到解锁高度的情况,只有受益人才能认领。
Clarinet.test({
name: "Allows the beneficiary to claim the balance when the block-height is reached",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const targetBlockHeight = 10;
const amount = 10;
chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(targetBlockHeight), types.uint(amount)], deployer.address),
]);
// Advance the chain until the unlock height.
chain.mineEmptyBlockUntil(targetBlockHeight);
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'claim', [], beneficiary.address),
]);
// The claim was successful and the STX were transferred.
block.receipts[0].result.expectOk().expectBool(true);
block.receipts[0].events.expectSTXTransferEvent(amount, `${deployer.address}.timelocked-wallet`, beneficiary.address);
}
});
Clarinet.test({
name: "Does not allow the beneficiary to claim the balance before the block-height is reached",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const targetBlockHeight = 10;
const amount = 10;
chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(targetBlockHeight), types.uint(amount)], deployer.address),
]);
// Advance the chain until the unlock height minus one.
chain.mineEmptyBlockUntil(targetBlockHeight - 1);
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'claim', [], beneficiary.address),
]);
// Should return err-unlock-height-not-reached (err u105).
block.receipts[0].result.expectErr().expectUint(105);
assertEquals(block.receipts[0].events.length, 0);
}
});
Clarinet.test({
name: "Does not allow anyone else to claim the balance when the block-height is reached",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const beneficiary = accounts.get('wallet_1')!;
const other = accounts.get('wallet_2')!;
const targetBlockHeight = 10;
const amount = 10;
chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'lock', [types.principal(beneficiary.address), types.uint(targetBlockHeight), types.uint(amount)], deployer.address),
]);
// Advance the chain until the unlock height.
chain.mineEmptyBlockUntil(targetBlockHeight);
const block = chain.mineBlock([
Tx.contractCall('timelocked-wallet', 'claim', [], other.address),
]);
// Should return err-beneficiary-only (err u104).
block.receipts[0].result.expectErr().expectUint(104);
assertEquals(block.receipts[0].events.length, 0);
}
});
该项目的完整源代码可以在这里找到 : https://github.com/clarity-lang/book/tree/main/projects/timelocked-wallet.