ACS1 - 交易费标准

ACS1用于管理交易的手续费。

接口

继承了ACS1的合约需要实现以下接口:

  • SetMethodFee,参数为acs1.proto中自定义的MethodFees类型,用于设置该合约中某个方法的Method Fee,即交易费信息。
  • GetMethodFee,参数为protobuf中的StringValue类型,返回值是MethodFees类型,用于获取某个方法的Method Fee。
  • ChangeMethodFeeController,参数为authority_info.proto中定义的AuthorityInfo类型,由于理论上不允许每个人都能够设置交易费,因此SetMethodFee这个方法的调用需要配置权限,该方法用于修改能够调用SetMethodFee方法的账户地址。
  • GetMethodFeeController,用于获取该合约SetMethodFee调用权限相关的的AuthorityInfo信息。

注意:仅有主链的系统合约才能实现ACS1。

应用

在AElf主链中,每当执行一个交易的时候,会通过FeeChargePreExecutionPlugin来为该交易生成一个pre-plugin交易;新生成的交易会在原交易之前执行,其作用就是收取原交易发送者一定数额的交易费。

新生成的交易对应的合约方法为ChargeTransactionFees,实现大致为(省略了部分代码):

/// <summary>
/// Related transactions will be generated by acs1 pre-plugin service,
/// and will be executed before the origin transaction.
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public override BoolValue ChargeTransactionFees(ChargeTransactionFeesInput input)
{
    // ...
    // Record tx fee bill during current charging process.
    var bill = new TransactionFeeBill();
    var fromAddress = Context.Sender;
    var methodFees = Context.Call<MethodFees>(input.ContractAddress, nameof(GetMethodFee),
        new StringValue {Value = input.MethodName});
    var successToChargeBaseFee = true;
    if (methodFees != null && methodFees.Fees.Any())
    {
        successToChargeBaseFee = ChargeBaseFee(GetBaseFeeDictionary(methodFees), ref bill);
    }
    var successToChargeSizeFee = true;
    if (!IsMethodFeeSetToZero(methodFees))
    {
        // Then also do not charge size fee.
        successToChargeSizeFee = ChargeSizeFee(input, ref bill);
    }
    // Update balances.
    foreach (var tokenToAmount in bill.FeesMap)
    {
        ModifyBalance(fromAddress, tokenToAmount.Key, -tokenToAmount.Value);
        Context.Fire(new TransactionFeeCharged
        {
            Symbol = tokenToAmount.Key,
            Amount = tokenToAmount.Value
        });
        if (tokenToAmount.Value == 0)
        {
            //Context.LogDebug(() => $"Maybe incorrect charged tx fee of {tokenToAmount.Key}: it's 0.");
        }
    }
    return new BoolValue {Value = successToChargeBaseFee && successToChargeSizeFee};
}

该方法分为两部分来收取交易费:

  1. 首先调用原交易目标合约的GetMethodFee方法(15行),获取要收取的费用,随后查询交易发送者的对应额度是否足够,如果足够的话就将这一部分费用(命名为Base Fee)计入账单(bill变量),不够的话意味着交易费收取失败,原交易因此不会被执行。
  2. 随后判断原合约的开发者是否故意将交易中对应的方法的手续费设置为0,如果故意设置为0则不需要进一步收取Size Fee,否则要根据交易的大小收取一笔Size Fee。

两部分费用收取成功后,就会直接修改交易发送者的对应token的余额,同时抛出一个TransactionFeeCharged事件。

链上会捕获并处理该事件,计算该区块中收取的交易费的总额度,在下一个区块中,会通过一个名为ClaimTransactionFees的系统交易,将所有该区块中收取的交易费销毁百分之10,其余百分之90区分主侧链,主链会打入主链分红池,侧链会打给事先设定的FeeReceiver地址。具体代码为:

/// <summary>
/// Burn 10% of tx fees.
/// If Side Chain didn't set FeeReceiver, burn all.
/// </summary>
/// <param name="symbol"></param>
/// <param name="totalAmount"></param>
private void TransferTransactionFeesToFeeReceiver(string symbol, long totalAmount)
{
    Context.LogDebug(() => "Transfer transaction fee to receiver.");
    if (totalAmount <= 0) return;
    var burnAmount = totalAmount.Div(10);
    if (burnAmount > 0)
        Context.SendInline(Context.Self, nameof(Burn), new BurnInput
        {
            Symbol = symbol,
            Amount = burnAmount
        });
    var transferAmount = totalAmount.Sub(burnAmount);
    if (transferAmount == 0)
        return;
    var treasuryContractAddress =
        Context.GetContractAddressByName(SmartContractConstants.TreasuryContractSystemName);
    if ( treasuryContractAddress!= null)
    {
        // Main chain would donate tx fees to dividend pool.
        if (State.DividendPoolContract.Value == null)
            State.DividendPoolContract.Value = treasuryContractAddress;
        State.DividendPoolContract.Donate.Send(new DonateInput
        {
            Symbol = symbol,
            Amount = transferAmount
        });
    }
    else
    {
        if (State.FeeReceiver.Value != null)
        {
            Context.SendInline(Context.Self, nameof(Transfer), new TransferInput
            {
                To = State.FeeReceiver.Value,
                Symbol = symbol,
                Amount = transferAmount,
            });
        }
        else
        {
            // Burn all!
            Context.SendInline(Context.Self, nameof(Burn), new BurnInput
            {
                Symbol = symbol,
                Amount = transferAmount
            });
        }
    }
}

In this way, AElf charges the transaction fee via the GetMethodFee provided by ACS1, and the other three methods are used to help with the implementations of GetMethodFee.

接口实现

最简单的方法就是仅实现GetMethodFee接口。

假如某个合约中事关业务逻辑的方法有Foo1、Foo2、Bar1、Bar2四个,定价分别为1、1、2、2个ELF,且这四个方法的交易费以后不会轻易修改的话,可以实现为:

public override MethodFees GetMethodFee(StringValue input)
{
    if (input.Value == nameof(Foo1) || input.Value == nameof(Foo2))
    {
        return new MethodFees
        {
            MethodName = input.Value,
            Fees =
            {
                new MethodFee
                {
                    BasicFee = 1_00000000,
                    Symbol = Context.Variables.NativeSymbol
                }
            }
        };
    }
    if (input.Value == nameof(Bar1) || input.Value == nameof(Bar2))
    {
        return new MethodFees
        {
            MethodName = input.Value,
            Fees =
            {
                new MethodFee
                {
                    BasicFee = 2_00000000,
                    Symbol = Context.Variables.NativeSymbol
                }
            }
        };
    }
    return new MethodFees();
}

这种实现仅可以通过升级合约来修改交易费,不过无需实现另外三个接口。

而较为推荐的实现需要在该合约的State文件中定义一个MappedState:

public MappedState<string, MethodFees> TransactionFees { get; set; }

在SetMethodFee方法中修改TransactionFees这个数据结构,在GetMethodFee方法中直接返回该数据结构中的值。

这个方案中,GetMethodFee的实现极其简单:

public override MethodFees GetMethodFee(StringValue input)
    return State.TransactionFees[input.Value];
}

而SetMethodFee的实现则需要加入权限管理,毕竟合约开发者不会希望自己的合约方法的交易费被别人随意修改。

参考MultiToken合约,可以实现为:

首先在State文件中定义一个单例的AuthorityInfo类型(定义在authority_info.proto中):

public SingletonState<AuthorityInfo> MethodFeeController { get; set; }

然后使用该类型的OwnerAddress判断Sender是否为指定的地址。

public override Empty SetMethodFee(MethodFees input)
{
  foreach (var symbolToAmount in input.Fees)
  {
     AssertValidToken(symbolToAmount.Symbol, symbolToAmount.BasicFee); 
  }
  RequiredMethodFeeControllerSet();
  Assert(Context.Sender ==             State.MethodFeeController.Value.OwnerAddress, "Unauthorized to set method fee.");
    State.TransactionFees[input.MethodName] = input;
    return new Empty();
}

其中AssertValidToken仅仅是看看设置的token是否存在,试图设置的BasicFee是否合理,等等。

关于权限检查的代码为第8行和第9行,其中RequiredMethodFeeControllerSet仅仅是为了防止权限之前没有设置过。

如果没有设置过权限,那就进议会(Parliament)合约的默认地址才能够调用SetMethodFee方法。如果某个方法通过议会的默认地址发出,意味着有2/3的区块生产者同意了相应的提案。

private void RequiredMethodFeeControllerSet()
{
   if (State.MethodFeeController.Value != null) return;
   if (State.ParliamentContract.Value == null)
   {
     State.ParliamentContract.Value =         Context.GetContractAddressByName(SmartContractConstants.ParliamentContractSystemName);
   }
   var defaultAuthority = new AuthorityInfo();
   // Parliament Auth Contract maybe not deployed.
   if (State.ParliamentContract.Value != null)
   {
     defaultAuthority.OwnerAddress =               State.ParliamentContract.GetDefaultOrganizationAddress.Call(new Empty());
     defaultAuthority.ContractAddress = State.ParliamentContract.Value;
   }
   State.MethodFeeController.Value = defaultAuthority;
}

当然SetMethodFee的调用权限也是能改的,前提是修改权限的交易由议会合约的默认地址发出:

public override Empty ChangeMethodFeeController(AuthorityInfo input)
{
    RequiredMethodFeeControllerSet();
    AssertSenderAddressWith(State.MethodFeeController.Value.OwnerAddress);
    var organizationExist = CheckOrganizationExist(input);
    Assert(organizationExist, "Invalid authority input.");
    State.MethodFeeController.Value = input;
    return new Empty();
}

GetMethodFeeController的方法就极为简单了:

public override AuthorityInfo GetMethodFeeController(Empty input)
{
    RequiredMethodFeeControllerSet();
    return State.MethodFeeController.Value;
}

以上为实现ACS1的可能两种的方式,不过大部分实现会使用二者混合:根据使用场景,一部分方法设置一个固定的MethodFee,此时只收取Base Fee,另一部分方法不设置,只收取Size Fee。

测试

构造相应的Stub,测试GetMethodFee和GetMethodFeeController能够返回预期值即可。

示例

AElf所有的系统合约都实现了ACS1,可以作为参考。