ACS3 - 提案合约标准

使用authority_info.proto中定义的AuthorityInfo类型配合以下代码可以限制合约的某个方法必须由某一个地址来调用:

Assert(Context.Sender == State.AuthorityInfo.Value.OwnerAddress, "No permission.");

When a method needs to be agreed by multiple parties, the above solution is obviously inadequate. At this time, you can consider using some of the interfaces provided by ACS3.

接口

如果要实现多个地址统一某个方案后,该方案能够执行,可以实现ACS3中定义的以下方法:

  • CreateProposal,所谓的Proposal(提案)其实是指定了某个合约某个方法,使用某个参数,当该提案被多个地址同意后,就可以释放:以一个虚拟地址作为Sender,通过发送inline交易来执行这个方法,因此参数CreateProposalInput中定义的是最终要执行的inline交易的基本信息,返回值是一个Hash,用来唯一标识这个提案;
  • Approve、Reject、Abstain,参数都是Hash,即CreateProposal创建出来的提案唯一标识,称之为提案Id,分别用来对某个提案进行同意、反对、弃权。
  • Release,参数为提案Id,用于释放提案:当要求达到的话,就可以释放;
  • ClearProposal,用于清除提案,减少State中存储的信息。

可以看到,某个提案在释放之前,具备投票权的账户可以进行同意、反对、弃权,而具体哪些账户有权利进行投票呢?ACS3在以上几个接口的基础上,引入了Organization(组织)的概念。也就是说,某个提案从创建开始,就依附于某个组织,而只有该组织的成员才可以进行投票。

不过由于组织的形式各有不同,因此Organization这个数据结构需要实现了ACS3的合约自行定义,这里给出一个例子:

message Organization {
    acs3.ProposalReleaseThreshold proposal_release_threshold = 1;
    string token_symbol = 2;
    aelf.Address organization_address = 3;
    aelf.Hash organization_hash = 4;
    acs3.ProposerWhiteList proposer_white_list = 5;
}

Because each organization has a default virtual address, adding the code like the begining at this document can verify if the sender is authorized.

Assert(Context.Sender == someOrganization.OrganizationAddress, "No permission.");

怎么知道一个组织对某个提案达成了什么意见?ACS3中定义了一个名为ProposalReleaseThreshold的数据结构:

message ProposalReleaseThreshold {
    int64 minimal_approval_threshold = 1;
    int64 maximal_rejection_threshold = 2;
    int64 maximal_abstention_threshold = 3;
    int64 minimal_vote_threshold = 4;
}

通过以上的数据结构,来决定这个组织对某个提案达成了什么样的意见,这个提案才能够进行释放:

  • 最少需要有多少票投给了同意;
  • 最多有多少票投给了反对;
  • 最多有多少票投给了弃权;
  • 最少需要有多少人参与了投票。

ACS3中与管理组织相关的接口有:

  • ChangeOrganizationThreshold,其参数正是刚提到的ProposalReleaseThreshold,用于修改某个组织的提案被释放之前,需要达到的要求,当然这个方法本身也需要做一下权限控制;
  • ChangeOrganizationProposerWhiteList,组织可以限制哪些地址能够发起提案,其参数为ProposerWhiteList,定义在acs3.proto,实际上就是一个Address列表;
  • CreateProposalBySystemContract,本意上是系统合约可以通过这个接口,以别的虚拟地址的名义发起提案,即存在一些Sender存在特权,且Sender必须为某个合约,真正实现的时候,特权合约包括哪些可以自行定义;

以上都是Action类型的接口,还有几个View类型的接口用于查询:

  • GetProposal,用于查询提案详情;
  • ValidateOrganizationExist,用于查询该合约中是否存在某个组织;
  • ValidateProposerInWhiteList,用于查询某个地址是否在某组织的提案白名单(Proposer White List)中。

接口实现

这里假设某个合约中只存在一个组织,也就是不需要专门定义Organization类型下,ACS3的实现。由于不会显式声明和创建组织,组织的提案白名单也就不存在。这里的处理方式是投票的人必须使用某种代币进行投票,这样可以在管理该代币的角度限制提案人——具有该代币,就意味着具有投票权。

简单起见,这里只实现CreateProposal、Approve、Reject、Abstain、Release这几个最核心的方法。

必要的State属性只有两个:

public MappedState<Hash, ProposalInfo> Proposals { get; set; }
public SingletonState<ProposalReleaseThreshold> ProposalReleaseThreshold { get; set; }

The Proposals stores all proposal's information, and the ProposalReleaseThreshold is used to save the requirements that the contract needs to meet to release the proposal.

合约初始化的时候,设置一下最简单的提案释放要求:

public override Empty Initialize(Empty input)
{
    State.TokenContract.Value =
        Context.GetContractAddressByName(SmartContractConstants.TokenContractSystemName);
    State.ProposalReleaseThreshold.Value = new ProposalReleaseThreshold
    {
        MinimalApprovalThreshold = 1,
        MinimalVoteThreshold = 1
    };
    return new Empty();
}

翻译一下提案释放要求:至少需要一个人投票,至少需要一个人投同意,即可。 创建提案:

public override Hash CreateProposal(CreateProposalInput input)
{
    var proposalId = Context.GenerateId(Context.Self, input.Token);
    Assert(State.Proposals[proposalId] == null, "Proposal with same token already exists.");
    State.Proposals[proposalId] = new ProposalInfo
    {
        ProposalId = proposalId,
        Proposer = Context.Sender,
        ContractMethodName = input.ContractMethodName,
        Params = input.Params,
        ExpiredTime = input.ExpiredTime,
        ToAddress = input.ToAddress,
        ProposalDescriptionUrl = input.ProposalDescriptionUrl
    };
    return proposalId;
}

投票

public override Empty Abstain(Hash input)
{
    Charge();
    var proposal = State.Proposals[input];
    if (proposal == null)
    {
        throw new AssertionException("Proposal not found.");
    }
    proposal.Abstentions.Add(Context.Sender);
    State.Proposals[input] = proposal;
    return new Empty();
}
public override Empty Approve(Hash input)
{
    Charge();
    var proposal = State.Proposals[input];
    if (proposal == null)
    {
        throw new AssertionException("Proposal not found.");
    }
    proposal.Approvals.Add(Context.Sender);
    State.Proposals[input] = proposal;
    return new Empty();
}
public override Empty Reject(Hash input)
{
    Charge();
    var proposal = State.Proposals[input];
    if (proposal == null)
    {
        throw new AssertionException("Proposal not found.");
    }
    proposal.Rejections.Add(Context.Sender);
    State.Proposals[input] = proposal;
    return new Empty();
}
private void Charge()
{
    State.TokenContract.TransferFrom.Send(new TransferFromInput
    {
        From = Context.Sender,
        To = Context.Self,
        Symbol = Context.Variables.NativeSymbol,
        Amount = 1_00000000
    });
}

释放也只是统计一下这个提案的投票状况,这里给出一个推荐的实现:

public override Empty Release(Hash input)
{
    var proposal = State.Proposals[input];
    if (proposal == null)
    {
        throw new AssertionException("Proposal not found.");
    }
    Assert(IsReleaseThresholdReached(proposal), "Didn't reach release threshold.");
    Context.SendInline(proposal.ToAddress, proposal.ContractMethodName, proposal.Params);
    return new Empty();
}
private bool IsReleaseThresholdReached(ProposalInfo proposal)
{
    var isRejected = IsProposalRejected(proposal);
    if (isRejected)
        return false;
    var isAbstained = IsProposalAbstained(proposal);
    return !isAbstained && CheckEnoughVoteAndApprovals(proposal);
}
private bool IsProposalRejected(ProposalInfo proposal)
{
    var rejectionMemberCount = proposal.Rejections.Count;
    return rejectionMemberCount > State.ProposalReleaseThreshold.Value.MaximalRejectionThreshold;
}
private bool IsProposalAbstained(ProposalInfo proposal)
{
    var abstentionMemberCount = proposal.Abstentions.Count;
    return abstentionMemberCount > State.ProposalReleaseThreshold.Value.MaximalAbstentionThreshold;
}
private bool CheckEnoughVoteAndApprovals(ProposalInfo proposal)
{
    var approvedMemberCount = proposal.Approvals.Count;
    var isApprovalEnough =
        approvedMemberCount >= State.ProposalReleaseThreshold.Value.MinimalApprovalThreshold;
    if (!isApprovalEnough)
        return false;
    var isVoteThresholdReached =
        proposal.Abstentions.Concat(proposal.Approvals).Concat(proposal.Rejections).Count() >=
        State.ProposalReleaseThreshold.Value.MinimalVoteThreshold;
    return isVoteThresholdReached;
}

测试

测试之前,在刚刚实现了ACS3的合约中,加入两个方法,用来作为提案的对象。

在State文件中定义一个string类型的单例:

public StringState Slogan { get; set; }

然后实现一对Set/Get方法:

public override StringValue GetSlogan(Empty input)
{
    return State.Slogan.Value == null ? new StringValue() : new StringValue {Value = State.Slogan.Value};
}
public override Empty SetSlogan(StringValue input)
{
    Assert(Context.Sender == Context.Self, "No permission.");
    State.Slogan.Value = input.Value;
    return new Empty();
}

这样,测试的时候对SetSlogan创建提案,通过、释放以后使用GetSlogan方法查看Slogan是否被修改即可。

准备一个实现了ACS3的合约的Stub:

var keyPair = SampleECKeyPairs.KeyPairs[0];
var acs3DemoContractStub =
    GetTester<ACS3DemoContractContainer.ACS3DemoContractStub>(DAppContractAddress, keyPair);

由于投票需要让合约对用户进行收费,因此需要调用一下Token合约的Approve方法做一下预授权:

var tokenContractStub =
    GetTester<TokenContractContainer.TokenContractStub>(
        GetAddress(TokenSmartContractAddressNameProvider.StringName), keyPair);
await tokenContractStub.Approve.SendAsync(new ApproveInput
{
    Spender = DAppContractAddress,
    Symbol = "ELF",
    Amount = long.MaxValue
});

创建一个提案,目标方法为SetSlogan,这里我们想把Slogan改成“AElf”:

var proposalId = (await acs3DemoContractStub.CreateProposal.SendAsync(new CreateProposalInput
{
    ContractMethodName = nameof(acs3DemoContractStub.SetSlogan),
    ToAddress = DAppContractAddress,
    ExpiredTime = TimestampHelper.GetUtcNow().AddHours(1),
    Params = new StringValue {Value = "AElf"}.ToByteString(),
    Token = HashHelper.ComputeFrom("AElf")
})).Output;

确定在此时,Slogan还是一个空字符串,然后进行投票:

// Check slogan
{
    var slogan = await acs3DemoContractStub.GetSlogan.CallAsync(new Empty());
    slogan.Value.ShouldBeEmpty();
}
await acs3DemoContractStub.Approve.SendAsync(proposalId);

释放提案,随后Slogan变成了“AElf”:

await acs3DemoContractStub.Release.SendAsync(proposalId);
// Check slogan
{
    var slogan = await acs3DemoContractStub.GetSlogan.CallAsync(new Empty());
    slogan.Value.ShouldBe("AElf");
}