原英文文档: https://book.clarity-lang.org/ch08-03-multi-signature-vault.html
如何写一个多人签名金库的智能合约
区块链技术使我们能够去中心化地处理很多问题,不仅仅限于管理加密资产。另一个流行的研究领域是去中心化的治理。任何形式的投票都有可能是一个非常不透明的过程。无论是对音乐点播台中最流行的歌曲的投票,还是对政府官员的投票,参与者都无法验证这个过程是否公平,或者结果是否真实。DAO (去中心化自治组织)可以改变这一切。DAO 是一个智能合约程序,它形成了某种类型的决策权,投票的过程基于 dao 成员的个人行为。
DAO 可以非常复杂,具有多层次的管理、资产委托授权和成员管理。有些甚至有自己的代币,作为所有权股份或者访问权! 传统公司结构的大部分(不是全部)可以转化为智能合约,以公平性的区块链技术和现实中的法律来共同治理公司。因此,DAO 的潜力不容小觑。
对于这个项目,我们将创建一个简化的 DAO ,允许其成员投票决定哪个委托人可以提取 DAO 的代币余额。DAO 在部署时将被初始化一次,之后成员可以投票赞成或反对特定的委托人。
功能
智能合约部署者只有初始化智能合约的能力,然后运行其过程。初始化调用将定义成员(负责人的列表)和允许撤回余额所需的投票数。
投票机制将按以下方式运作。
成员可以对任何委托人投出赞成 /反对票。
对同一委托人再次投票将取代旧的投票。
任何人都可以检查投票的状态。
任何人都可以对某一特定用户的所有投票进行统计。
一旦一个委托人达到所需的票数,它就可以提取代币。
常量和变量
我们从通常的常量开始,以定义智能合约的所有者和错误代码。说到错误,我们可以预料到在初始化步骤上有三种失败。
所有者以外的人试图初始化智能合约。
金库已经被锁定。
初始化调用指定了一个所需的投票量,而这个投票量大于所有成员的数量。
投票过程本身只有在非成员试图投票时才会失败。最后,只有在达到投票所需票数的情况下,提取代币的功能才会执行成功。
;; 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-more-votes-than-members-required (err u102))
(define-constant err-not-a-member (err u103))
(define-constant err-votes-required-not-met (err u104))
成员将被存储在一个具有一定最大长度的列表中。投票本身将被存储在一个地图中,该地图使用一个有两个值的元组键:发出投票的成员的负责人和被投票的负责人。
;; Variables
(define-data-var members (list 100 principal) (list))
(define-data-var votes-required uint u1)
(define-map votes {member: principal, recipient: principal} {decision: bool})
对于一个简单的投票形智能合约程序,将成员存储在一个列表中是可以接受的。它还允许我们以一些有趣的方式练习对列表进行迭代。然而,需要注意的是,这样的成员列表对于大型项目来说是不够的,因为这样操作就会变得成本昂贵。关于最佳实践的章节涵盖了列表的一些用途和可能的误用。
执行 start 函数
start 函数将被智能合约所有者调用,以初始化金库。这是一个简单的函数,它用适当的防护措施更新两个变量的位置。
(define-public (start (new-members (list 100 principal)) (new-votes-required uint))
(begin
(asserts! (is-eq tx-sender contract-owner) err-owner-only)
(asserts! (is-eq (len (var-get members)) u0) err-already-locked)
(asserts! (>= (len new-members) new-votes-required) err-more-votes-than-members-required)
(var-set members new-members)
(var-set votes-required new-votes-required)
(ok true)
)
)
执行 vote 函数
vote 函数就更直接了。我们所要做的就是确保 tx-sender 是其中一个成员。我们可以通过使用内置的 index-of 函数来检查 tx-sender 是否存在于成员列表中。它返回一个 optional 的类型,所以我们可以简单地检查它是否返回一个(some ...),而不是一个 none 。
(define-public (vote (recipient principal) (decision bool))
(begin
(asserts! (is-some (index-of (var-get members) tx-sender)) err-not-a-member)
(ok (map-set votes {member: tx-sender, recipient: recipient} {decision: decision}))
)
)
当我们在做这件事的时候,让我们也添加一个只读函数来检索投票。如果一个成员以前从未为某一特定的用户投票,我们将默认它为 false 的负面投票。
(define-read-only (get-vote (member principal) (recipient principal))
(default-to false (get decision (map-get? votes {member: member, recipient: recipient})))
)
在这个函数中,有很多事情要做。下面是一步步要发生的情况。
使用 map-get?函数 来检索投票数组。该函数将返回一个 some 或 none 。
get 函数返回一个数组中指定键的值。如果 get 被提供了一个(some tuple),它将返回一个(some value )。如果 get 被提供了 none ,它将返回 none 。
default-to 函数试图对 get 函数的结果进行解包。如果它是一个 some 函数,它返回被包装的值。如果它是 none ,它将返回默认值,在这种情况下是 false 。
统计票数
现在的挑战是如何创建一个可以计算出对某位用户的赞成票数的函数。我们必须对成员进行迭代,检索他们的投票,并在投票为真时增加一个计数器。由于 Clarity 是非图灵完备的,无限的 for-loops 循环是不可能的。在关于序列的章节中,我们了解到只有两种方法可以在一个列表上进行迭代,即使用 map 或 fold 函数。
选择使用 map 还是 fold 归结为一个简单的问题:结果应该是另一个列表还是一个奇数?
我们想把列表中的成员减少到一个代表积极投票总数的数字;也就是说,我们需要使用 fold 函数。首先我们再看一下函数签名。
(fold accumulator-function input-list initial-value)
fold 函数 将对 input-list 进行迭代,为列表中的每个元素调用 accumulator-function 函数。这个函数会接收两个参数:列表中的下一个成员和上一个 accumulator 的值。accumulator-function 函数返回的值被用作下一个 accumulator 调用的输入。
由于我们想计算正数的投票数量,我们应该只在对用户的投票为 true 时才增加 accumulator 的值。没有内置函数可以做到这一点,所以我们必须创建一个自定义的 accumulator ,作为一个私有函数。
(define-private (tally (member principal) (accumulator uint))
(if (get-vote member tx-sender) (+ accumulator u1) accumulator)
)
(define-read-only (tally-votes)
(fold tally (var-get members) u0)
)
tally-votes 函数返回对成员列表的折叠结果。我们的自定义 accumulator 函数 tally 调用列表中当前的成员和 tx-sender 函数,还有我们先前创建的 get-vote 只读函数。这个调用的结果将是 true 或 false 。如果结果为 true ,那么 tally 返回 accumulator 的增量为 1 。否则,它只返回当前 accumulator 的值。
if 表达式的解包:
(if
(get-vote member tx-sender) ;; The condition (boolean expression).
(+ accumulator u1) ;; Value to return if the condition is true.
accumulator ;; Value to return if the condition is false.
)
由于 tally-votes 是一个只读函数,它可以被任何 tx-sender 用户调用,而不需要发送交易。非常方便。
执行 withdraw 函数
我们已经拥有创建 withdraw 函数所需的一切。它将统计 tx-sender 的票数并检查它是否大于或等于所需的票数。如果交易发送者通过了阀值,智能合约应将其所有的余额转让给 tx-sender 。
(define-public (withdraw)
(let
(
(recipient tx-sender)
(total-votes (tally-votes))
)
(asserts! (>= total-votes (var-get votes-required)) err-votes-required-not-met)
(try! (as-contract (stx-transfer? (stx-get-balance tx-sender) tx-sender recipient)))
(ok total-votes)
)
)
为了方便起见,总票数被返回,这样它就可以被记录在区块链上,也许会被调用的应用程序使用。
Deposit convenience 函数
最后,我们将添加一个 Deposit convenience 函数,将代币存入合约程序。这绝对不是必需的,因为用户可以直接将代币转移到合约程序本金中。这个函数在以后编写单元测试时将会很有用。
(define-public (deposit (amount uint))
(stx-transfer? amount tx-sender (as-contract tx-sender))
)
单元测试
现在是时候了,我们开始通过添加可重用的部分使我们的单元测试变得更容易管理。我们将定义一堆标准值并创建一个 setup 函数来初始化智能合约。然后,该函数可以在各种测试的开始被调用,以处理调用 start 函数和通过调用 deposit 函数进行初始化 STX 代币存款的事宜。
const contractName = 'multisig-vault';
const defaultStxVaultAmount = 5000;
const defaultMembers = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3', 'wallet_4'];
const defaultVotesRequired = defaultMembers.length - 1;
type InitContractOptions = {
chain: Chain,
accounts: Map<string, Account>,
members?: Array<string>,
votesRequired?: number,
stxVaultAmount?: number
};
function initContract({ chain, accounts, members = defaultMembers, votesRequired = defaultVotesRequired, stxVaultAmount = defaultStxVaultAmount }: InitContractOptions) {
const deployer = accounts.get('deployer')!;
const contractPrincipal = `${deployer.address}.${contractName}`;
const memberAccounts = members.map(name => accounts.get(name)!);
const nonMemberAccounts = Array.from(accounts.keys()).filter(key => !members.includes(key)).map(name => accounts.get(name)!);
const startBlock = chain.mineBlock([
Tx.contractCall(contractName, 'start', [types.list(memberAccounts.map(account => types.principal(account.address))), types.uint(votesRequired)], deployer.address),
Tx.contractCall(contractName, 'deposit', [types.uint(stxVaultAmount)], deployer.address),
]);
return { deployer, contractPrincipal, memberAccounts, nonMemberAccounts, startBlock };
}
测试 start 函数
让我们先把 start 函数的测试做出来。
智能合约的所有者可以初始化金库。
其他任何人都不能初始化保险库。
保险库只能被初始化一次。
Clarinet.test({
name: "Allows the contract owner to initialise the vault",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const memberB = accounts.get('wallet_1')!;
const votesRequired = 1;
const memberList = types.list([types.principal(deployer.address), types.principal(memberB.address)]);
const block = chain.mineBlock([
Tx.contractCall(contractName, 'start', [memberList, types.uint(votesRequired)], deployer.address)
]);
block.receipts[0].result.expectOk().expectBool(true);
}
});
Clarinet.test({
name: "Does not allow anyone else to initialise the vault",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const memberB = accounts.get('wallet_1')!;
const votesRequired = 1;
const memberList = types.list([types.principal(deployer.address), types.principal(memberB.address)]);
const block = chain.mineBlock([
Tx.contractCall(contractName, 'start', [memberList, types.uint(votesRequired)], memberB.address)
]);
block.receipts[0].result.expectErr().expectUint(100);
}
});
Clarinet.test({
name: "Cannot start the vault more than once",
async fn(chain: Chain, accounts: Map<string, Account>) {
const deployer = accounts.get('deployer')!;
const memberB = accounts.get('wallet_1')!;
const votesRequired = 1;
const memberList = types.list([types.principal(deployer.address), types.principal(memberB.address)]);
const block = chain.mineBlock([
Tx.contractCall(contractName, 'start', [memberList, types.uint(votesRequired)], deployer.address),
Tx.contractCall(contractName, 'start', [memberList, types.uint(votesRequired)], deployer.address)
]);
block.receipts[0].result.expectOk().expectBool(true);
block.receipts[1].result.expectErr().expectUint(101);
}
});
Clarinet.test({
name: "Cannot require more votes than members",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { startBlock } = initContract({ chain, accounts, votesRequired: defaultMembers.length + 1 });
startBlock.receipts[0].result.expectErr().expectUint(102);
}
});
测试 vote 函数
只允许会员可以成功调用 vote 函数。如果非成员调用该函数,它也应该返回正确的错误响应。
Clarinet.test({
name: "Allows members to vote",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts, deployer } = initContract({ chain, accounts });
const votes = memberAccounts.map(account => Tx.contractCall(contractName, 'vote', [types.principal(deployer.address), types.bool(true)], account.address));
const block = chain.mineBlock(votes);
block.receipts.map(receipt => receipt.result.expectOk().expectBool(true));
}
});
Clarinet.test({
name: "Does not allow non-members to vote",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { nonMemberAccounts, deployer } = initContract({ chain, accounts });
const votes = nonMemberAccounts.map(account => Tx.contractCall(contractName, 'vote', [types.principal(deployer.address), types.bool(true)], account.address));
const block = chain.mineBlock(votes);
block.receipts.map(receipt => receipt.result.expectErr().expectUint(103));
}
});
测试 get-vote 函数
get-vote 是一个简单的只读函数,用于返回成员-接收者组合的布尔型投票状态。
Clarinet.test({
name: "Can retrieve a member's vote for a principal",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts, deployer } = initContract({ chain, accounts });
const [memberA] = memberAccounts;
const vote = types.bool(true);
chain.mineBlock([
Tx.contractCall(contractName, 'vote', [types.principal(deployer.address), vote], memberA.address)
]);
const receipt = chain.callReadOnlyFn(contractName, 'get-vote', [types.principal(memberA.address), types.principal(deployer.address)], memberA.address);
receipt.result.expectBool(true);
}
});
测试 withdraw 函数
如果达到阈值,withdraw 函数返回一个 ok 响应,其中包含 tx-sender 的总票数。否则,它返回一个( err u104 )( err-votes-required-not-met )。
Clarinet.test({
name: "Principal that meets the vote threshold can withdraw the vault balance",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { contractPrincipal, memberAccounts } = initContract({ chain, accounts });
const recipient = memberAccounts.shift()!;
const votes = memberAccounts.map(account => Tx.contractCall(contractName, 'vote', [types.principal(recipient.address), types.bool(true)], account.address));
chain.mineBlock(votes);
const block = chain.mineBlock([
Tx.contractCall(contractName, 'withdraw', [], recipient.address)
]);
block.receipts[0].result.expectOk().expectUint(votes.length);
block.receipts[0].events.expectSTXTransferEvent(defaultStxVaultAmount, contractPrincipal, recipient.address);
}
});
Clarinet.test({
name: "Principals that do not meet the vote threshold cannot withdraw the vault balance",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts, nonMemberAccounts } = initContract({ chain, accounts });
const recipient = memberAccounts.shift()!;
const [nonMemberA] = nonMemberAccounts;
const votes = memberAccounts.slice(0, defaultVotesRequired - 1).map(account => Tx.contractCall(contractName, 'vote', [types.principal(recipient.address), types.bool(true)], account.address));
chain.mineBlock(votes);
const block = chain.mineBlock([
Tx.contractCall(contractName, 'withdraw', [], recipient.address),
Tx.contractCall(contractName, 'withdraw', [], nonMemberA.address)
]);
block.receipts.map(receipt => receipt.result.expectErr().expectUint(104));
}
});
测试改变投票
会员有能力在任何时候改变他们的投票。因此,我们将增加一个最后的测试,即投票的改变导致接收者不再有资格认领金库的余额。
Clarinet.test({
name: "Members can change votes at-will, thus making an eligible recipient uneligible again",
async fn(chain: Chain, accounts: Map<string, Account>) {
const { memberAccounts } = initContract({ chain, accounts });
const recipient = memberAccounts.shift()!;
const votes = memberAccounts.map(account => Tx.contractCall(contractName, 'vote', [types.principal(recipient.address), types.bool(true)], account.address));
chain.mineBlock(votes);
const receipt = chain.callReadOnlyFn(contractName, 'tally-votes', [], recipient.address);
receipt.result.expectUint(votes.length);
const block = chain.mineBlock([
Tx.contractCall(contractName, 'vote', [types.principal(recipient.address), types.bool(false)], memberAccounts[0].address),
Tx.contractCall(contractName, 'withdraw', [], recipient.address),
]);
block.receipts[0].result.expectOk().expectBool(true);
block.receipts[1].result.expectErr().expectUint(104);
}
});
该项目的完整源代码可以在这里找到: https://github.com/clarity-lang/book/tree/main/projects/multisig-vault 。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.