Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce intrinsic fees for transaction validation beyond script & byte costs #529

Merged
merged 46 commits into from
Dec 12, 2023
Merged
Changes from 35 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
0568022
add dynamic policy configuration transactions
Voxelot Aug 24, 2023
769cd39
remove extra newline
Voxelot Aug 24, 2023
5cd6f11
add to summary
Voxelot Aug 24, 2023
0d89c7e
PR feedback
Voxelot Sep 26, 2023
137e3a6
move gas price to policies
Voxelot Oct 11, 2023
ff4fca4
lint
Voxelot Oct 11, 2023
6988d8e
Merge branch 'master' into Voxelot/tx-policies
Voxelot Oct 11, 2023
da1d621
Update src/tx-format/policy.md
Voxelot Oct 12, 2023
29a5b71
fixes
Voxelot Oct 12, 2023
389fb66
add additional GTF args for policy metadata
Voxelot Oct 12, 2023
7fd36a6
experiment with bitmask approach for policy types to compress tx
Voxelot Oct 13, 2023
573be18
add max_fee policy
Voxelot Oct 20, 2023
faa4c6e
Merge remote-tracking branch 'origin/master' into Voxelot/tx-policies
Voxelot Oct 20, 2023
6deff2a
merge with master
Voxelot Oct 20, 2023
6cd617a
Update src/fuel-vm/instruction-set.md
Voxelot Oct 23, 2023
ca66b7e
make gasLimit only applicable to script txs and remove it as a policy…
Voxelot Oct 25, 2023
a346591
use count_ones() instead of len() for GTF_POLICY_COUNT
Voxelot Oct 25, 2023
505a23d
add some intrinsic costs for transaction inputs
Voxelot Oct 26, 2023
90687c6
add some intrinsic costs for transaction processing
Voxelot Oct 26, 2023
7829f93
some minor corrections
Voxelot Oct 26, 2023
3465f6f
cleanup
Voxelot Oct 26, 2023
4fc24ab
cleanup
Voxelot Oct 26, 2023
46236b0
add cost of script vm init
Voxelot Oct 26, 2023
d013eb3
fix max_fee rule
Voxelot Oct 26, 2023
d1bdaaf
Merge branch 'master' into Voxelot/intrinsic-costs
bvrooman Nov 29, 2023
3a88d5b
Fix merge
bvrooman Nov 29, 2023
1d92526
Minor corrections to calculations
bvrooman Nov 29, 2023
69c00c2
Fix python formatting
bvrooman Nov 29, 2023
8254b3d
Add section on fees
bvrooman Nov 29, 2023
8648ac4
Update tx-validity.md
bvrooman Nov 30, 2023
664a49b
Update tx-validity.md
bvrooman Nov 30, 2023
797ad78
Elaboration
bvrooman Nov 30, 2023
766c941
Merge branch 'master' into Voxelot/intrinsic-costs
bvrooman Dec 1, 2023
7aa7556
Feedback
bvrooman Dec 2, 2023
a0c5977
Correct metadata calculation
bvrooman Dec 2, 2023
1d8e25e
Apply suggestions from code review
Dec 6, 2023
40b9fc7
Feedback
bvrooman Dec 11, 2023
f911b3d
Merge branch 'Voxelot/intrinsic-costs' of https://github.com/FuelLabs…
bvrooman Dec 11, 2023
1328e2a
snake case to camel case for variables
bvrooman Dec 11, 2023
1d8b1c9
Update snake case to camel case
bvrooman Dec 11, 2023
29c0323
Apply suggestions from code review
Dec 11, 2023
f3d57ca
Update notes on max gas
bvrooman Dec 11, 2023
03c32ff
Merge branch 'Voxelot/intrinsic-costs' of https://github.com/FuelLabs…
bvrooman Dec 11, 2023
8393b7e
fix typo
bvrooman Dec 11, 2023
954b22d
Fix max_gas fn
bvrooman Dec 11, 2023
1b6e806
Update tx-validity.md
bvrooman Dec 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 149 additions & 38 deletions src/protocol/tx-validity.md
Original file line number Diff line number Diff line change
@@ -33,9 +33,9 @@ Read-only access list:
Write-destroy access list:

- For each [input `InputType.Coin`](../tx-format/input.md#inputcoin)
- The [UTXO ID](../identifiers/utxo-id.md) `(txID, outputIndex)`
- The [UTXO ID](../identifiers/utxo-id.md) `(tx_id, output_index)`
- For each [input `InputType.Contract`](../tx-format/input.md#inputcontract)
- The [UTXO ID](../identifiers/utxo-id.md) `(txID, outputIndex)`
- The [UTXO ID](../identifiers/utxo-id.md) `(tx_id, output_index)`
- For each [input `InputType.Message`](../tx-format/input.md#inputmessage)
- The [message ID](../identifiers/utxo-id.md#message-id) `messageID`

@@ -46,15 +46,15 @@ Write-create access list:
- For each output
- The [created UTXO ID](../identifiers/utxo-id.md)

Note that block proposers use the contract ID `contractID` for inputs and outputs of type [`InputType.Contract`](../tx-format/input.md#inputcontract) and [`OutputType.Contract`](../tx-format/output.md#outputcontract) rather than the pair of `txID` and `outputIndex`.
Note that block proposers use the contract ID `contractID` for inputs and outputs of type [`InputType.Contract`](../tx-format/input.md#inputcontract) and [`OutputType.Contract`](../tx-format/output.md#outputcontract) rather than the pair of `tx_id` and `output_index`.

## VM Precondition Validity Rules

This section defines _VM precondition validity rules_ for transactions: the bare minimum required to accept an unconfirmed transaction into a mempool, and preconditions that the VM assumes to hold prior to execution. Chains of unconfirmed transactions are omitted.

For a transaction `tx`, UTXO set `state`, contract set `contracts`, and message set `messages`, the following checks must pass.

> **Note:** [InputMessages](../tx-format/input.md#inputmessage) where `input.dataLength > 0` are not dropped from the `messages` message set until they are included in a transaction of type `TransactionType.Script` with a `ScriptResult` receipt where `result` is equal to `0` indicating a successful script exit.
> **Note:** [InputMessages](../tx-format/input.md#inputmessage) where `input.data_length > 0` are not dropped from the `messages` message set until they are included in a transaction of type `TransactionType.Script` with a `ScriptResult` receipt where `result` is equal to `0` indicating a successful script exit.

### Base Sanity Checks

@@ -71,89 +71,168 @@ for input in tx.inputs:
if not input.nonce in messages:
return False
else:
if not (input.txID, input.outputIndex) in state:
if not (input.tx_id, input.output_index) in state:
return False
return True
```

If this check passes, the UTXO ID `(txID, outputIndex)` fields of each contract input is set to the UTXO ID of the respective contract. The `txPointer` of each input is also set to the TX pointer of the UTXO with ID `utxoID`.
If this check passes, the UTXO ID `(tx_id, output_index)` fields of each contract input is set to the UTXO ID of the respective contract. The `txPointer` of each input is also set to the TX pointer of the UTXO with ID `utxoID`.

### Sufficient Balance

For each asset ID `asset_id` in the input and output set:

```py
def gas_to_fee(gas, gas_price) -> int:
"""
Converts gas units into a fee amount
"""
return ceil(gas * gas_price / GAS_PRICE_FACTOR)


def sum_data_messages(tx, asset_id) -> int:
"""
Returns the total balance available from messages containing data
"""
total: int = 0
if asset_id == 0:
for input in tx.inputs:
if input.type == InputType.Message and input.dataLength > 0:
if input.type == InputType.Message and input.data_length > 0:
total += input.amount
return total


def sum_inputs(tx, asset_id) -> int:
total: int = 0
for input in tx.inputs:
if input.type == InputType.Coin and input.asset_id == asset_id:
total += input.amount
elif input.type == InputType.Message and asset_id == 0 and input.dataLength == 0:
elif input.type == InputType.Message and asset_id == 0 and input.data_length == 0:
total += input.amount
return total

def sum_predicate_gas_used(tx) -> int:
total: int = 0
for input in tx.inputs:
if input.type == InputType.Coin:
total += input.predicateGasUsed
elif input.type == InputType.Message:
total += input.predicateGasUsed
return total

"""
Returns any minted amounts by the transaction
"""
def transaction_size_gas_fees(tx) -> int:
"""
Computes the intrinsic gas cost of a transaction based on size in bytes
"""
size(tx) * GAS_PER_BYTE


def minted(tx, asset_id) -> int:
"""
Returns any minted amounts by the transaction
"""
if tx.type != TransactionType.Mint or asset_id != tx.mint_asset_id:
return 0
return tx.mint_amount


def sum_outputs(tx, asset_id) -> int:
total: int = 0
for output in tx.outputs:
if output.type == OutputType.Coin and output.asset_id == asset_id:
total += output.amount
return total


def input_gas_fees(tx) -> int:
"""
Computes the intrinsic gas cost of verifying input utxos
"""
total: int = 0
witness_indices = set(())
for input in tx.inputs:
if input.type == InputType.Coin or input.type == InputType.Message:
# add fees allocated for predicate execution
if input.predicate_length == 0:
# notate witness index if input is signed
witness_indices.add(input.witness_index)
else:
# add intrinsic gas cost of predicate merkleization based on number of predicate bytes
total += contract_code_root_gas_fee(input.predicate_length)
total += input.predicate_gas_used
# add intrinsic cost of vm initialization
total += vm_initialization_gas_fee()
# add intrinsic cost of verifying witness signatures
total += len(witness_indices) * eck1_recover_gas_fee()
return total


def metadata_gas_fees(tx) -> int:
"""
Computes the intrinsic gas cost of processing transaction outputs
"""
total: int = 0
if tx.type == TransactionType.Create:
for output in tx.outputs:
if output.type == OutputType.OutputContractCreated:
# add intrinsic cost of calculating the code root based on the size of the contract bytecode
total += contract_code_root_gas_fee(tx.witnesses[tx.bytecode_witness_index].data_length)
# add intrinsic cost of calculating the state root based on the number of sotrage slots
total += contract_state_root_gas_fee(tx.storage_slot_count)
# add intrinsic cost of calculating the contract id
# size = 4 byte seed + 32 byte salt + 32 byte code root + 32 byte state root
total += sha256_gas_fee(100)
# add intrinsic cost of calculating the transaction id
total += sha256_gas_fee(size(tx))
elif tx.type == TransactionType.Script:
# add intrinsic cost of calculating the transaction id
total += sha256_gas_fee(size(tx))
return total


def intrinsic_gas_fees(tx) -> int:
"""
Computes intrinsic costs for a transaction
"""
fees: int = 0
# add the cost of initializing a vm for the script
if tx.type == TransactionType.Create or tx.type == TransactionType.Script:
fees += vm_initialization_gas_fee()
fees += metadata_gas_fees(tx)
fees += intrinsic_input_gas_fees(tx)
return fees


def gas_balance(tx) -> int:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gas_balance sound strange=) Maybe max_gas would be better?

Copy link
Contributor

@bvrooman bvrooman Dec 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function (if I understand correctly) is meant to calculate how much the user owes (AKA the balance) after transaction execution. I can update the comment to reflect this (because currently it is misleading).

In contrast, max_gas is the maximum gas that the user is willing to spend.

Edit: It looks like this is meant to be the max_gas, and we are later converting max_gas to a balance (reserved_fee_balance). It sounds like we reserve the max gas. We don't actually specify the formula for the actual cost here (which is fine).

"""
Computes the maximum amount of gas required to process a transaction.
"""
gas = tx.gasLimit + transaction_size_gas_fees(tx) + intrinsic_gas_fees(tx)
return gas


def reserved_fee_balance(tx, asset_id) -> int:
"""
Computes the maximum potential amount of fees that may need to be charged to process a transaction.
"""
gas_balance = gas_balance(tx)
fee_balance = gas_to_fee(gas_balance, tx.gasPrice)
# Only base asset can be used to pay for gas
if asset_id == 0:
return fee_balance
else:
return 0


def available_balance(tx, asset_id) -> int:
"""
Make the data message balance available to the script
"""
availableBalance = sum_inputs(tx, asset_id) + sum_data_messages(tx, asset_id) + minted(tx, asset_id)
return availableBalance


def unavailable_balance(tx, asset_id) -> int:
sentBalance = sum_outputs(tx, asset_id)
# Total fee balance
feeBalance = fee_balance(tx, asset_id)
feeBalance = reserved_fee_balance(tx, asset_id)
# Only base asset can be used to pay for gas
if asset_id == 0:
return sentBalance + feeBalance
return sentBalance

def fee_balance(tx, asset_id) -> int:
gas = tx.scriptGasLimit + sum_predicate_gas_used(tx)
gasBalance = gasPrice * gas / GAS_PRICE_FACTOR
bytesBalance = size(tx) * GAS_PER_BYTE * gasPrice / GAS_PRICE_FACTOR
# Total fee balance
feeBalance = ceiling(gasBalance + bytesBalance)
# Only base asset can be used to pay for gas
if asset_id == 0:
return feeBalance
else:
return 0

# The sum_data_messages total is not included in the unavailable_balance since it is spendable as long as there
# is enough base asset amount to cover gas costs without using data messages. Messages containing data can't
@@ -168,12 +247,12 @@ def address_from(pubkey: bytes) -> bytes:
return sha256(pubkey)[0:32]

for input in tx.inputs:
if (input.type == InputType.Coin || input.type == InputType.Message) and input.predicateLength == 0:
if (input.type == InputType.Coin or input.type == InputType.Message) and input.predicate_length == 0:
# ECDSA signatures must be 64 bytes
if tx.witnesses[input.witnessIndex].dataLength != 64:
if tx.witnesses[input.witness_index].data_length != 64:
return False
# Signature must be from owner
if address_from(ecrecover_k1(txhash(), tx.witnesses[input.witnessIndex].data)) != input.owner:
if address_from(ecrecover_k1(txhash(), tx.witnesses[input.witness_index].data)) != input.owner:
return False
return True
```
@@ -184,7 +263,7 @@ The transaction hash is computed as defined [here](../identifiers/transaction-id

## Predicate Verification

For each input of type `InputType.Coin` or `InputType.Message`, and `predicateLength > 0`, [verify its predicate](../fuel-vm/index.md#predicate-verification).
For each input of type `InputType.Coin` or `InputType.Message`, and `predicate_length > 0`, [verify its predicate](../fuel-vm/index.md#predicate-verification).

## Script Execution

@@ -205,13 +284,45 @@ Once the free balances are computed, the [script is executed](../fuel-vm/index.m
1. The unspent free balance `unspentBalance` for each asset ID.
1. The unspent gas `unspentGas` from the `$ggas` register.

The fees incurred for a transaction are `ceiling(((size(tx) * GAS_PER_BYTE) + (tx.scriptGasLimit - unspentGas) + sum(tx.inputs[i].predicateGasUsed)) * tx.gasPrice / GAS_PRICE_FACTOR)`.

`size(tx)` includes the entire transaction serialized according to the transaction format, including witness data.
`size(tx)` encompasses the entire transaction serialized according to the transaction format, including witness data.
This ensures every byte of block space either on Fuel or corresponding DA layer can be accounted for.

If the transaction as included in a block does not match this final transaction, the block is invalid.

### Fees

The cost of a transaction can be described by:

```py
cost(tx) = gas_to_fee(gas_cost(tx) - unspentGas, tx.gasPrice)
```

where:

- `gas_cost(tx)` is the final cost of the transaction in gas, including gas fees incurred from:
- Intrinsic fees:
- The number of bytes comprising the transaction
- Processing inputs and outputs
- VM initialization
- Predicate and script execution
- `unspentGas` is the amount gas left over after intrinsic fees and execution of the transaction, extracted from the `$ggas` register. Converting unspent gas to a fee describes how much "change" is left over from the user's payment; the block producer collects this unspent gas as reward.
- `gas_to_fee` is a function that converts gas to a concrete fee based on a given gas price.

Fees incurred by transaction processing outside the context of execution are collectively referred to as intrinsic fees. Intrinsic fees include the cost of storing the transaction, calculated on a per-byte basis, the cost of processing inputs and outputs, including signature verification, and initialization of the VM prior to any predicate or script execution. Because intrinsic fees are independent of execution, they can be determined _a priori_ and represent the bare minimum cost of the transaction.

Users wishing to submit transactions can incentivize block producers to include their transactions by providing a higher reward in the form of more unspent gas. This is achieved by specifying a higher [gas limit](../tx-format/transaction.md) on the transaction.

A naturally occurring result of a variable gas limit is the concept of minimum and maximum fees. The minimum fee is, thus, the exact fee required to pay the fee balance, while the maximum fee is the minimum fee plus the gas limit:

```py
min_gas = min_gas(tx)
max_gas = min_gas + tx.gasLimit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The maxWitness - witnessSize(tx) also affects the maximum gas=)

Copy link
Contributor

@bvrooman bvrooman Dec 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused by how max gas is now calculated.

I see in the trait definition of max_gas() is:

fn max_gas(&self, gas_costs: &GasCosts, fee: &FeeParameters) -> Word {
    let remaining_allowed_witness_gas = self
        .witness_limit()
        .saturating_sub(self.witnesses().size_dynamic() as u64)
        .saturating_mul(fee.gas_per_byte);

    self.min_gas(gas_costs, fee)
        .saturating_add(remaining_allowed_witness_gas)
}

This means:

M a x G a s = M i n G a s + ( W i t n e s s B y t e s L i m i t W i t n e s s B y t e s ) G a s P e r B y t e

In this case, there's no mention of gas limit.

In some checked_transaction tests (e.g. fee_multiple_signed_inputs):

...
let max_fee = fee.max_fee();
let expected_max_fee = expected_min_fee + gas_limit * gas_price;
assert_eq!(max_fee, expected_max_fee);

This means:

M a x G a s = M i n G a s + G a s L i m i t

In this case, there's no mention of witnesses.

Is there a connection between gas limit and witnesses now? I.e., G a s L i m i t = ( W i t n e s s B y t e s L i m i t W i t n e s s B y t e s ) G a s P e r B y t e
?

Copy link
Contributor

@xgreenx xgreenx Dec 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are looking into max_gas implementation for Create transaction. The Script transaction implements this method in another place.

image

The final formula looks like:

M a x G a s = M i n G a s + ( W i t n e s s B y t e s L i m i t A c t u a l W i t n e s s B y t e s ) G a s P e r B y t e + G a s L i m i t

min_fee = gas_to_fee(min_gas, tx.gasPrice)
max_fee = gas_to_fee(max_gas, tx.gasPrice)
```

The cost of the transaction `cost(tx)` must lie within the range defined by [`min_fee`, `max_fee`]. `min_gas` is defined as the sum of all intrinsic costs of the transaction known prior to execution. The definition of `max_gas` illustrates that the delta between minimum gas and maximum gas is the user-defined `tx.gasLimit`. A transaction cost `cost(tx)`, in gas, greater than `max_gas` is invalid and must be rejected; this signifies that the user must provide a higher gas limit for the given transaction. `min_fee` is the minimum reward the producer is guaranteed to collect, and `max_fee` is the maximum reward the producer is potentially eligible to collect. In practice, the user is always charged intrinsic fees; thus, `unspentGas` is the remainder of `max_gas` after intrinsic fees and the variable cost of execution. Calculating a conversion from `unspentGas` to an unspent fee describes the reward the producer will collect.

## VM Postcondition Validity Rules

This section defines _VM postcondition validity rules_ for transactions: the requirements for a transaction to be valid after it has been executed.
2 changes: 1 addition & 1 deletion src/tx-format/policy.md
Original file line number Diff line number Diff line change
@@ -51,4 +51,4 @@ Transaction is invalid if:
Transaction is invalid if:

- `max_fee > sum_inputs(tx, BASE_ASSET_ID) - sum_outputs(tx, BASE_ASSET_ID)`
- `max_fee < fee_balance(tx, BASE_ASSET_ID)`
- `max_fee < reserved_fee_balance(tx, BASE_ASSET_ID)`
1 change: 1 addition & 0 deletions src/tx-format/transaction.md
Original file line number Diff line number Diff line change
@@ -80,6 +80,7 @@ Given helper `sum_variants()` that sums all variants of an enum.

Transaction is invalid if:

- More than one output is of type `OutputType.Change` with identical `asset_id` fields.
- Any output is of type `OutputType.ContractCreated`
- `scriptLength > MAX_SCRIPT_LENGTH`
- `scriptDataLength > MAX_SCRIPT_DATA_LENGTH`