Rework the mapping from iotago errors to TransactionFailureReason.
The main motivation is to give better, more descriptive errors when it matters, which is important for the developer experience.
The approach here was to go through all errors in vm/vm.go and vm/nova/vm.go and check which errors actually can occur. Occasionally, we also call out into other parts of the codebase, and those were checked on a best-effort basis.
However, it is not necessarily desirable to map every single possible error case to a TransactionFailureReason, for the following reasons:
Some errors exist to wrap other errors, such as ErrInvalidBlockIssuerTransition or ErrInvalidStakingTransition. Those are mainly meant to avoid the need to prefix the more specific block issuer or staking errors with "invalid block issuer/staking feature transition: ...". We do not want to map those in the failure reason map for reasons discussed below.
iotago.ErrTransDepIdentOutputNonUTXOChainID and iotago.ErrTransDepIdentOutputNextInvalid are also not mapped, because they are implementation-specific errors. If they actually occurred, it would indicate an implementation error of the Nova VM. Hence, under normal circumstances, the VM will never return these errors for a given (valid or invalid) TX.
iotago.ErrUTXOInputInvalid (used in iota-core) is no longer mapped either, because it too seems to only be used as a node-internal error, such as when accessing the KV Store when it is closed or when deserialization of an output fails from the stored bytes, which is fine to represent as the generic, catch-all TxFailureSemanticValidationFailed, should it actually ever occur.
Some other errors that were previously mapped are no longer mapped:
iotago.ErrUnknownInputType is no longer mapped because the validity of input types is checked syntactically and the error is never returned from the VM.
iotago.ErrUnknownOutputType is similarly no longer mapped because it is never thrown in the VM.
Error level of detail
Another important point is that TransactionFailureReason is a simple byte, which brings along some challenges and trade-offs that need to be made. In that context, one interesting case are errors classes that can occur for multiple output types. For instance, there is ErrNewChainOutputHasNonZeroedID, for when a newly created chain output type has a non-zeroed chain ID. One could easily argue that there should be a dedicated error type for every chain output type: Account, Anchor, NFT, Foundry and Delegation Output. Since the TransactionFailureReason is just a byte, it's not possible to associate an error with an output type or even the concrete output index for which this error occured. In the absence of that, we need to make a choice whether to create dedicated error types for each case, e.g. ErrNewAccountOutputHasNonZeroedID, ErrNewAnchorOutputHasNonZeroedID, and so on, or if a single error is good enough.
The choice for this particular case is to use a single error. The rationale that could be applied here for figuring out, whether or not dedicated errors are needed, is whether debugging a transaction with such an error would be easy: Is it reasonably simple to find which chain output has the non-zeroed ID? Probably yes.
On the other hand, is InvalidStakingFeatureTransition easy enough to debug? Most likely not. So the more intricate the validation rules are, the more likely it is that dedicated errors are needed. Hence, why there are various specific error variants for Staking Feature, Block Issuer Feature and Delegation Output transition.
As a final example, there are also various cases in which a "CommitmentInputMissing" error is returned. However, since many different parts of a transaction could've caused the need for a Commitment Input, more specific errors are introduced, since a single one would be too unhelpful. Examples include:
TxFailureDelegationCommitmentInputMissing
TxFailureTimelockCommitmentInputMissing
TxFailureExpirationCommitmentInputMissing
...
One future improvement could be to make the failure reason not just a simple byte, but consist of multiple ones, where one indicates the error and another the offending output's index. Or one might even consider making such errors algebraic data types, where the failure reason can be associated with as much helpful information as is sensible.
Unlock errors
One area that could still see improvements is the input unlock errors. In this PR it was extended to have three different errors for chain address, direct unlockable and multi address errors. Judging by above's rationale, since the rules of how Signature Unlocks, Reference Unlocks and the chain-specific unlocks can be combined are fairly complex, we could consider making even better errors there.
Mapping the error
When we have an error like return ierrors.Join(iotago.ErrInvalidDirectUnlockableAddressUnlock, iotago.ErrUnlockSignatureInvalid, err), where we want both of those errors to be joined for an overall more helpful error message, and both are also part of the failure map, then we need to make a decision which one to map. The chosen approach is to map the more detailed error, since that should almost always be more helpful for debugging. In this case, iotago.ErrUnlockSignatureInvalid should be mapped. That means we cannot use the existing approach of iterating through the failure map and checking if any error is in the to-be-mapped error using ierrors.Is(...). Instead, we should get the error chain, and iterate it from back to front, so that we map the first, most-detailed error that is in the error chain.
This is also much more efficient, since the previous approach (still used by the block failure determining code), runs through the entire error chain for every single error in the map, which is n*m time complexity.
The new approach walks the error chain once and does len(error chain) number of lookups in the map.
Since error chains are now effectively trees, we walk the tree in a post-order, depth-first traversal to collect the errors, to get the desired result.
VM Improvements
Some noteworthy things were also done as part of this PR:
The AccountOutputSet and AnchorOutputSet were removed, since they were dead code.
The check for who is allowed to unlock an Expiration UC was duplicated and that part was deduplicated.
Similarly for the Account locked check, based on its BIC. It was duplicated in the Implicit Account code path.
Other Notes
Some error messages were also improved along the way: We should prefer formatting xyz.Type() as %s rather than xyz as %T, so we get the stringified type instead of the Go type. We should not leak Go type strings into the error messages, as not every user or API consumer is using Go or familiar with it.
Some error messages are explicitly formatted with Errorf over WithMessagef so we get slightly nicer error messages (specifically to avoid the : inserted by WithMessagef):
"invalid unlock for multi address: invalid unlock for chain address 0x18bf8697430bff9afc755c3001eee52a2a261dfa881c641e35506bd1050cc390fe (AnchorAddress): input 2 is not unlocked through input 0's unlock"
Other improvements to error messages, examples (lines breaks added for readability):
before:
"input 1's address is already unlocked through input &{824639622464 0 map[]}'s unlock
but the input uses a non referential unlock: invalid input unlock"
after:
"invalid unlock for direct unlockable address:
input 1's address is already unlocked through input 0's unlock but the input
uses a non referential unlock of type SignatureUnlock"
before:
"input 1 has a chain address (*iotago.AccountAddress) but its corresponding
unlock is of type *iotago.ReferenceUnlock: invalid input unlock"
after:
"invalid unlock for chain address: input 1 has a chain address of type AccountAddress
but its corresponding unlock is of type ReferenceUnlock"
Description
Rework the mapping from iotago
error
s toTransactionFailureReason
.The main motivation is to give better, more descriptive errors when it matters, which is important for the developer experience.
The approach here was to go through all errors in
vm/vm.go
andvm/nova/vm.go
and check which errors actually can occur. Occasionally, we also call out into other parts of the codebase, and those were checked on a best-effort basis.However, it is not necessarily desirable to map every single possible error case to a
TransactionFailureReason
, for the following reasons:ErrInvalidBlockIssuerTransition
orErrInvalidStakingTransition
. Those are mainly meant to avoid the need to prefix the more specific block issuer or staking errors with "invalid block issuer/staking feature transition: ...". We do not want to map those in the failure reason map for reasons discussed below.iotago.ErrTransDepIdentOutputNonUTXOChainID
andiotago.ErrTransDepIdentOutputNextInvalid
are also not mapped, because they are implementation-specific errors. If they actually occurred, it would indicate an implementation error of the Nova VM. Hence, under normal circumstances, the VM will never return these errors for a given (valid or invalid) TX.iotago.ErrUTXOInputInvalid
(used in iota-core) is no longer mapped either, because it too seems to only be used as a node-internal error, such as when accessing the KV Store when it is closed or when deserialization of an output fails from the stored bytes, which is fine to represent as the generic, catch-allTxFailureSemanticValidationFailed
, should it actually ever occur.Some other errors that were previously mapped are no longer mapped:
iotago.ErrUnknownInputType
is no longer mapped because the validity of input types is checked syntactically and the error is never returned from the VM.iotago.ErrUnknownOutputType
is similarly no longer mapped because it is never thrown in the VM.Error level of detail
Another important point is that
TransactionFailureReason
is a simple byte, which brings along some challenges and trade-offs that need to be made. In that context, one interesting case are errors classes that can occur for multiple output types. For instance, there isErrNewChainOutputHasNonZeroedID
, for when a newly created chain output type has a non-zeroed chain ID. One could easily argue that there should be a dedicated error type for every chain output type: Account, Anchor, NFT, Foundry and Delegation Output. Since theTransactionFailureReason
is just a byte, it's not possible to associate an error with an output type or even the concrete output index for which this error occured. In the absence of that, we need to make a choice whether to create dedicated error types for each case, e.g.ErrNewAccountOutputHasNonZeroedID
,ErrNewAnchorOutputHasNonZeroedID
, and so on, or if a single error is good enough.The choice for this particular case is to use a single error. The rationale that could be applied here for figuring out, whether or not dedicated errors are needed, is whether debugging a transaction with such an error would be easy: Is it reasonably simple to find which chain output has the non-zeroed ID? Probably yes.
On the other hand, is
InvalidStakingFeatureTransition
easy enough to debug? Most likely not. So the more intricate the validation rules are, the more likely it is that dedicated errors are needed. Hence, why there are various specific error variants for Staking Feature, Block Issuer Feature and Delegation Output transition.As a final example, there are also various cases in which a "CommitmentInputMissing" error is returned. However, since many different parts of a transaction could've caused the need for a Commitment Input, more specific errors are introduced, since a single one would be too unhelpful. Examples include:
TxFailureDelegationCommitmentInputMissing
TxFailureTimelockCommitmentInputMissing
TxFailureExpirationCommitmentInputMissing
One future improvement could be to make the failure reason not just a simple byte, but consist of multiple ones, where one indicates the error and another the offending output's index. Or one might even consider making such errors algebraic data types, where the failure reason can be associated with as much helpful information as is sensible.
Unlock errors
One area that could still see improvements is the input unlock errors. In this PR it was extended to have three different errors for chain address, direct unlockable and multi address errors. Judging by above's rationale, since the rules of how Signature Unlocks, Reference Unlocks and the chain-specific unlocks can be combined are fairly complex, we could consider making even better errors there.
Mapping the error
When we have an error like
return ierrors.Join(iotago.ErrInvalidDirectUnlockableAddressUnlock, iotago.ErrUnlockSignatureInvalid, err)
, where we want both of those errors to be joined for an overall more helpful error message, and both are also part of the failure map, then we need to make a decision which one to map. The chosen approach is to map the more detailed error, since that should almost always be more helpful for debugging. In this case,iotago.ErrUnlockSignatureInvalid
should be mapped. That means we cannot use the existing approach of iterating through the failure map and checking if any error is in the to-be-mapped error usingierrors.Is(...)
. Instead, we should get the error chain, and iterate it from back to front, so that we map the first, most-detailed error that is in the error chain. This is also much more efficient, since the previous approach (still used by the block failure determining code), runs through the entire error chain for every single error in the map, which is n*m time complexity. The new approach walks the error chain once and doeslen(error chain)
number of lookups in the map. Since error chains are now effectively trees, we walk the tree in a post-order, depth-first traversal to collect the errors, to get the desired result.VM Improvements
Some noteworthy things were also done as part of this PR:
AccountOutputSet
andAnchorOutputSet
were removed, since they were dead code.Other Notes
Some error messages were also improved along the way: We should prefer formatting
xyz.Type()
as%s
rather thanxyz
as%T
, so we get the stringified type instead of the Go type. We should not leak Go type strings into the error messages, as not every user or API consumer is using Go or familiar with it.Some error messages are explicitly formatted with
Errorf
overWithMessagef
so we get slightly nicer error messages (specifically to avoid the:
inserted byWithMessagef
):before:
after:
before:
after: