Skip to content
This repository has been archived by the owner on Jul 9, 2021. It is now read-only.

[WIP] Rich Revert Reasons PoC (Exchange only for now) #1688

Conversation

merklejerk
Copy link
Contributor

Description

This is a proof-of-concept migration from regular string-based revert reasons to much more informative, ABI-encoded revert reasons. This is based around discussions of EIP 838.

NOTE I've only migrated the 0.4.x version of the exchange contracts because ATM other contract packages in development use a conflicting 0.5.x version of Solidity. Once PR #1682 for modernizing all the legacy contracts gets merged, I can start migrating en masse. But before then, we should have a discussion about this approach, because the implementation has some pain points and touches several packages.

Some Background

Current Solidity revert strings are already returned as calldata ABI-encoded bytes with a method signature of Error(string), but you can actually return any arbitrary bytes with an assembly call to revert().

Revert data can only be reliably recovered during an eth_call, because transactions only produce a hash and receipt, with a single status bit indicating success or failure. But a significant issue we've run into is just detecting a revert during an eth_call, particularly with Geth. A Geth node will simply return the revert bytes, with no signal that the call reverted. Parity supposedly returns a signal, but, as we don't currently test against Parity, I cannot personally confirm this. The current hack (already implemented in /packages/base-contract is to basically check if the returned data starts with the bytes4 selector of the standard Error signature.

Where We Are Now

There have been discussions around various schemes of returning rich revert data. The EVM does not seem to sanitize what gets returned via revert(), so many options are on the table. Ultimately, for this PoC, we've settled on simply ABI-encoding against custom method signatures for specific Exchange errors. One common example would be OrderStatusError(string,uint8), which encodes an order's hash and current status. This is thrown whenever the order is in an incompatible state during an operation.

While this approach is relatively simple and wire-compact, it does require prior knowledge of all revert signatures to semi-confidently discern which return values are true reverts and which are just arbitrary bytes. It is also not backwards compatible-- clients that only look for Error(string) signatures will likely either fail at the decoding step or miss these reverts entirely.

Significant additions have been made to the order-utils, test-utils, and abi-gen-templates packages to help intercept, decode, construct, and test against these new revert formats. Small modifications were also made to other sundry packages so they could anticipate the new reverts and pass their tests.

For the Solidity implementation, please take a look at /contracts/exchange/.../MRichErrors.sol. Usage is peppered among the various Exchange contracts (everywhere there used to be a require() or revert()).

The Typescript implementation currently resides at `/packages/order-utils/.../rich_reverts.ts. Some tests also exist to demonstrate usage (though more could be used).

There is also a chai extension implemented in contract-wrappers that demonstrates integration with chai. This will probably be moved to test-utils in future commits because that makes more sense.

Testing instructions

Just run the regular monorepo test suite since this PR spans many packages.

Types of changes

  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • Prefix PR title with [WIP] if necessary.
  • Add tests to cover changes as needed.
  • Update documentation as needed.
  • Add new entries to the relevant CHANGELOG.jsons.

currentAssetProxy == address(0),
"ASSET_PROXY_ALREADY_EXISTS"
);
if (currentAssetProxy != address(0))
Copy link
Member

Choose a reason for hiding this comment

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

Let's use brackets after all if statements please.

);

if (assetProxy == address(0))
rrevert(AssetProxyDispatchError(AssetProxyDispatchErrorCodes.UNKNOWN_ASSET_PROXY));
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if both of these errors AssetProxyDispatcher errors should just rethrow the entire assetData. Thoughts @fabioberger ?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I'd be in favor. Imagine you have an order that fails with the error. If we include the assetData that triggered these failures, it'll be easier to debug.

rrevert(
SignatureError(
orderInfo.orderHash,
SignatureErrorCodes.BAD_SIGNATURE
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, also wondering if the signature errors should be more specific. In general, it feels like we need to make some assumptions about what information the end user actually has.

Copy link
Contributor

Choose a reason for hiding this comment

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

I actually think this is fine. If logic inside the isValidSignature reverts, they will get a detailed error message for those specific issues. This one comes to down to ecrecover returning a different address, it not being presigned or the wallet contract returning false. Which one of those occured can be deduced by looking at the signature type passed in.

);

if (takerAssetFillAmount == 0)
rrevert(FillError(orderInfo.orderHash, FillErrorCodes.INVALID_TAKER_AMOUNT));
Copy link
Member

Choose a reason for hiding this comment

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

Why use rrevert over rrequire?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because it would look like rrequire(takerAssetFillAmount != 0, FillError(...)). It's a nicer experience, but it means the revert reason has to be ABI encoded before the condition is checked, prematurely wasting a lot of gas.

Copy link
Contributor

Choose a reason for hiding this comment

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

That answers my prev question. Makes sense

Copy link
Contributor

Choose a reason for hiding this comment

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

Solidity could potentially fix that on the language level later.

Copy link
Contributor

Choose a reason for hiding this comment

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

What about smth like condition || rrevert(Error(stuff))? Will it be lazy-evaluated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe. Will check on remix.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup, condition || rrevert(Error(stuff)); works. So that's an option if people want a more compact alternative.



// This should probably be moved to contracts-exchange-lib
contract MRichErrors
Copy link
Member

Choose a reason for hiding this comment

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

Some thoughts on naming here:

  • Contracts with the M prefix in the mixins directory don't contain actual implementations, just the function signatures (this is so that we can easily mock out the functions in tests, which we don't actually really do yet). The actual implementation of the contract would be prefixed with Mixin.
  • Since we'll have these in different packages going forward, maybe the name should be more specific to avoid naming collisions (like MixinExchangeRichErrors).

pure
{
assembly {
if iszero(and(success, 0x00000000000000000000000000000000000000000000000000000000000000FF)) {
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we actually need this mask?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is my concern: https://solidity.readthedocs.io/en/v0.5.5/security-considerations.html#minor-details

I tried it out in remix and function arguments don't get bitmasked before hitting the body, so the dirty higher bits passed into rrequire() persist.

/// may be preferable (gas-wise) to rrequire()
/// since you can avoid the needless abi encoding if the test passes.
/// @param errorData ABI encoded error data.
function rrevert(bytes memory errorData)
Copy link
Member

Choose a reason for hiding this comment

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

rrevert and rrequire should probably be in a separate contract in utils, since these will be reused in other packages.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely. Will move them once all contracts agree on solidity version.

@@ -1,6 +1,6 @@
{
"name": "@0x/contracts-exchange",
"version": "1.0.9",
"version": "1.1.0",
Copy link
Member

Choose a reason for hiding this comment

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

You don't actually need to bump versions in package.json (this happens automatically when we publish the packages). It shouldn't matter either way.

/**
* Base type for rich revert reasons.
*/
export abstract class RichRevertReason {
Copy link
Member

Choose a reason for hiding this comment

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

Does it make sense for this to be in the order-utils package? contract-wrappers feels more natural to me since we're decoding our contact's reverts specifically.

@hysz
Copy link
Contributor

hysz commented Mar 11, 2019

it does require prior knowledge of all revert signatures to semi-confidently discern which return values are true reverts and which are just arbitrary bytes

I wonder if it would make sense to use a generic signature, like ZeroExError(bytes), where the input bytes is a nested encoding of the error message. Now we have a single 4-byte selector to infer an error state.

Example:

errorMessage = abiEncode(OrderStatusError(string,uint8))`
zeroExErrorMessage = abiEncode(ZeroExError(errorMessage))

If this could be beneficial, it's worth noting that it shouldn't be too much additional cost. We just prepend the selector, a generic offset (0x..20) and the length of errorMessage (which we should already know).

Edit:
This could also make it easier for contracts to detect errors when calling NoThrow functions which simply copy relay the returndata.

@merklejerk
Copy link
Contributor Author

@hysz I like this. Much less chance of collisions with the selector bytes.

"note": "Add rich revert reasons"
}
]
},
Copy link
Contributor

Choose a reason for hiding this comment

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

You should append those changes to the previous entry. It doesn't have the timestamp which means - it's not published. Just change the version.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also add the PR number please 🙏

"ASSET_PROXY_ALREADY_EXISTS"
);
if (currentAssetProxy != address(0))
rrevert(AssetProxyExistsError(currentAssetProxy));
Copy link
Contributor

Choose a reason for hiding this comment

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

I like the require pattern. I think we should have smth like rrequire

);

if (takerAssetFillAmount == 0)
rrevert(FillError(orderInfo.orderHash, FillErrorCodes.INVALID_TAKER_AMOUNT));
Copy link
Contributor

Choose a reason for hiding this comment

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

That answers my prev question. Makes sense

);

if (takerAssetFillAmount == 0)
rrevert(FillError(orderInfo.orderHash, FillErrorCodes.INVALID_TAKER_AMOUNT));
Copy link
Contributor

Choose a reason for hiding this comment

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

Solidity could potentially fix that on the language level later.

);

if (takerAssetFillAmount == 0)
rrevert(FillError(orderInfo.orderHash, FillErrorCodes.INVALID_TAKER_AMOUNT));
Copy link
Contributor

Choose a reason for hiding this comment

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

What about smth like condition || rrevert(Error(stuff))? Will it be lazy-evaluated?

RICH_REVERT_REGISTRY,
);

const DECODER_CACHE: ObjectMap<Decoder> = _.zipObject(
Copy link
Contributor

Choose a reason for hiding this comment

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

Our naming convention for Objects is stuffToStuff1ToStuff2 etc...
For example selectorToDecoder

_.map(RICH_REVERT_REGISTRY, r => createDecoder(r.abi)),
);

function checkArgEquality(type: string, a: ArgTypes, b: ArgTypes): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Maybe name those params lhs and rhs?left hand side

};
}

function declarationToAbi(decl: string): RichRevertAbi {
Copy link
Contributor

Choose a reason for hiding this comment

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

Ethers.js should have a function to parse the ABI string. Check my prev PR

const assert = chai.assert;

// tslint:disable:custom-no-magic-numbers
describe.only('RichRevertReasons', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
describe.only('RichRevertReasons', () => {
describe('RichRevertReasons', () => {

"note": "Move OrderStatus enum from /contracts/test-utils into here"
}
]
},
Copy link
Contributor

Choose a reason for hiding this comment

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

Merge with prev entry

Copy link
Contributor

Choose a reason for hiding this comment

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

And PR # :)

"note": "Add rich revert reasons"
}
]
},
Copy link
Contributor

Choose a reason for hiding this comment

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

Also add the PR number please 🙏

);

if (assetProxy == address(0))
rrevert(AssetProxyDispatchError(AssetProxyDispatchErrorCodes.UNKNOWN_ASSET_PROXY));
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I'd be in favor. Imagine you have an order that fails with the error. If we include the assetData that triggered these failures, it'll be easier to debug.

rrevert(
SignatureError(
orderInfo.orderHash,
SignatureErrorCodes.BAD_SIGNATURE
Copy link
Contributor

Choose a reason for hiding this comment

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

I actually think this is fine. If logic inside the isValidSignature reverts, they will get a detailed error message for those specific issues. This one comes to down to ecrecover returning a different address, it not being presigned or the wallet contract returning false. Which one of those occured can be deduced by looking at the signature type passed in.

);

if (takerAssetFilledAmount > takerAssetFillAmount)
rrevert(FillError(orderInfo.orderHash, FillErrorCodes.TAKER_OVERPAY));
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's def. be using curly brackets for all conditionals.

if (safeMul(makerAssetFilledAmount, order.takerAssetAmount)
>
safeMul(order.makerAssetAmount, takerAssetFilledAmount)) {
rrevert(FillError(orderInfo.orderHash, FillErrorCodes.INVALID_FILL_PRICE));
Copy link
Contributor

Choose a reason for hiding this comment

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

Technically we'd need takerAssetFilledAmount and makerAssetFilledAmount here since without those values, one couldn't figure out what went wrong. However, this is a cautionary assertion that should never hit, so maybe we can pass on this one.

@@ -1,6 +1,6 @@
{
"name": "@0x/order-utils",
"version": "7.0.2",
"version": "7.1.0",
Copy link
Contributor

Choose a reason for hiding this comment

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

Revert. You get the drill ;)

@@ -72,3 +72,5 @@ export {
FeeOrdersAndRemainingFeeAmount,
OrdersAndRemainingFillAmount,
} from './types';

export * from './rich_reverts';
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd rather we explicitly list the items we want exported. Someone might add an export to that file and be surprise to find that it's been auto-magically exported by the package.

new IncompleteFillError(),
];

const RICH_REVERT_LUT: ObjectMap<RichRevertReason> = _.zipObject(
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use readable variable names over conciseness.


describe('decoding', () => {
interface DecoderTestData {
cls: { name: string };
Copy link
Contributor

Choose a reason for hiding this comment

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

cls?

"note": "Move OrderStatus enum from /contracts/test-utils into here"
}
]
},
Copy link
Contributor

Choose a reason for hiding this comment

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

And PR # :)

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants