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

Standardized errors #936

Open
itegulov opened this issue Oct 12, 2022 · 34 comments · May be fixed by #1165
Open

Standardized errors #936

itegulov opened this issue Oct 12, 2022 · 34 comments · May be fixed by #1165

Comments

@itegulov
Copy link
Contributor

itegulov commented Oct 12, 2022

Right now it is unclear what kind of errors a contract (or even a function) can fail with. It would be nice to have a standardized way of declaring contract errors, something like this:

#[near_error]
pub enum MyError {
  #[message("Caller is not the owner of this contract")]
  NotTheOwner,
  #[message("Caller is not allowed to call this function")]
  NotAllowed,
}

We can then include all contract errors as a part of the ABI, this proposal also goes really well with the Result-based error handling as we can statically infer what errors a function can fail with.

@uint
Copy link
Contributor

uint commented Jun 28, 2023

I assume tuple/struct variants would be forbidden here?

@frol
Copy link
Collaborator

frol commented Jun 28, 2023

I assume tuple/struct variants would be forbidden here?

@uint I don't think this is the intent.

This issue is more about making error handling explicit as currently the norm is that contract developers just panic in the middle of the execution, and that is treated as an error.

Let me bring some context here:

In NEAR, transaction execution often happens across several blocks (please, watch/read this nice article). Contract call action is processed when receipt is processed and if during the execution the function wants to make a cross-contract call or make a transfer it will initiate more receipts. Each receipt execution is atomic (either completely successful and the changes are committed to the blockchain or completely rejected (e.g. on panic) and changes are not saved). Note that transactions in NEAR (in contrast to individual receipts) are not atomic.

That said, panic is often used to indicate that no changes should be saved, but the result string and logs are still returned, see this example:

image

This issue suggests we standardize this error handling and instead of returning a text message, we actually return some structured error.

On a similar note, it is sometimes useful to communicate an error without failing the execution (let it save the changes it did so far) [here is one recent discussion about it], so it is worth addressing this scenario together while we are discussing it. cc @robert-zaremba @fadeevab

@uint
Copy link
Contributor

uint commented Jun 28, 2023

I looked into how things work a bit. If I understand correctly, the VM currently conflates these two ideas:

  • a guest error happened, and
  • a receipt rollback is needed.

It sounds a lot like you'd want to disentangle those in nearcore. Is that right? @frol

EDIT: I'm assuming that the VM needs to be aware of when an error happened even if no rollback is needed there.

@frol
Copy link
Collaborator

frol commented Jun 28, 2023

@uint Actually, I was not thinking about changing nearcore, at least not yet. I don't see a problem with receipts rolling back on guest error.

As proposed in the discussion on Linkdrop NEP that I referenced in the previous comment, it would be great to standardize Result::Err-like signaling from smart contract with an option to make it with and without rollback. I have not dived into the topic yet, so I only have half-backed and ugly solutions at the moment, but I will share them just to give you the direction of thoughts:

near-sdk-rs could abuse logs to serialize an error message similar to how events are already implemented. Logs are recorded even when receipt fails, so near-sdk-rs could handle return Result::Err value by logging the error value serialized as JSON before exiting. See how near_bindgen creates extern "C" functions for the struct methods:

#[test]
fn trait_implt() {
let impl_type: Type = syn::parse_str("Hello").unwrap();
let mut method: ImplItemMethod = syn::parse_str("fn method(&self) { }").unwrap();
let method_info = ImplItemMethodInfo::new(&mut method, true, impl_type).unwrap().unwrap();
let actual = method_info.method_wrapper();
let expected = quote!(
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn method() {
near_sdk::env::setup_panic_hook();
let contract: Hello = near_sdk::env::state_read().unwrap_or_default();
contract.method();
}
);
assert_eq!(expected.to_string(), actual.to_string());
}

and there is already #[handle_result] attribute which we might deprecate and handle Result "natively":

#[test]
fn handle_result_json() {
let impl_type: Type = syn::parse_str("Hello").unwrap();
let mut method: ImplItemMethod = parse_quote! {
#[handle_result]
pub fn method(&self) -> Result<u64, &'static str> { }
};
let method_info = ImplItemMethodInfo::new(&mut method, false, impl_type).unwrap().unwrap();
let actual = method_info.method_wrapper();
let expected = quote!(
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn method() {
near_sdk::env::setup_panic_hook();
let contract: Hello = near_sdk::env::state_read().unwrap_or_default();
let result = contract.method();
match result {
Ok(result) => {
let result =
near_sdk::serde_json::to_vec(&result).expect("Failed to serialize the return value using JSON.");
near_sdk::env::value_return(&result);
}
Err(err) => near_sdk::FunctionError::panic(&err)
}
}
);
assert_eq!(expected.to_string(), actual.to_string());
}

@uint
Copy link
Contributor

uint commented Jun 28, 2023

near-sdk-rs could abuse logs to serialize an error message similar to how events are already implemented.

Open questions/concerns about that:

  1. Do performance implications matter here? I imagine there'd be some extra parsing overhead when choosing this path.
  2. Is the schema always guaranteed to be textual and not binary (like borsh schema)? I noticed this is specified in one I generated:
    "serialization_type": "json",
    
  3. Can capturing the log errors be done at the SDK level? Seems important for cross-contract calls. (happy to research that one)

@frol
Copy link
Collaborator

frol commented Jun 30, 2023

@uint All great questions and I would like to invite @agostbiro here as well.

  1. Well, it is always a trade-off and the unfortunate part is that error return value and logs are required to be UTF-8 strings (OK result and input args to the function call actions are Vec<u8> which allows them to use any format, even though most often people use JSON - the only project that I know that uses binary data as input/output is Aurora Rainbow bridge). It is really unfortunate, and while we may try to relax the requirements on nearcore side, that requires someone to champion to work on handling all the breaking changes it may introduce (protocol, RPC, client libraries, SDK, CLIs)
  2. Nope, as I said above it could be binary (we may either hack it further and use base64 in that case, or change the protocol, as mentioned above)
  3. I don't remember from the top of my head, so need to look deeper into it (theoretically, I cannot think of why that would be impossible, but it could have been just not implemented in the protocol and then we are back to protocol change consideration and then we can design a more elegant solution without workarounds)

@agostbiro
Copy link
Contributor

Thanks for the tag @frol! Zooming out a bit, I'm strongly in support of promoting standardized errors for smart contracts. Having smart contracts fail with standardized error messages instead of panics would help with code reuse and testing and it'd be great if we could make it work with cross-contract calls as well.

To expand on @itegulov's suggestion we could provide a standard contract error implementation in this repo with well-defined semantics for common errors such as authorization or insufficient deposit. We could take JSON-RPC and HTTP 4xx errors as inspiration here. Custom errors could then be defined by the contract implementation.

The question is then how would standard errors and custom errors play together then. I see two ways:

  1. Have the standard encapsulate custom errors such as this
#[derive(thiserror::Error, Debug)]
pub enum StandardError {
    #[error("Caller is not the owner of this contract")]
    NotTheOwner,
    #[error("Caller is not allowed to call this function")]
    NotAllowed,
    #[error(transparent)]
    Custom(#[from] Box<dyn std::error::Error + Send + Sync>),
}
  1. Have the custom errors encapsulate standard errors such as this
#[derive(thiserror::Error, Debug)]
pub enum CustomError {
    #[error("Some contract specific error")]
    MyError,
    #[error(transparent)]
    Standard(#[from] StandardError),
}

The advantage of going with option 1 is that the most common error types are easy to handle and the same error type could be used by all contracts. The disadvantage is the type erasure in the StandardError::Custom which makes it tedious to work with inside a contract.

This code example explains the problems with option 1:

// Converting standard error to custom error is easy
let into_custom: CustomError = StandardError::NotAllowed.into();

// Converting from custom to standard requires implementing conversion.
// We could do this in a macro.
impl From<CustomError> for StandardError {
    fn from(error: CustomError) -> StandardError {
        match error {
            CustomError::Standard(standard) => standard,
            _ => StandardError::Custom(Box::new(error)),
        }
    }
}

let original_standard: StandardError = into_custom.into();
assert!(matches!(original_standard, StandardError::NotAllowed));

// Converting custom error into standard error causes type erasure though
let into_standard: StandardError = CustomError::MyError.into();
let erased_custom: CustomError = into_standard.into();
assert!(matches!(erased_custom, CustomError::Standard(_)));

// We can provide a method to return the custom error optionally, but using
// it is a bit tedious, since there is no guarantee that the downcast value
// is not a `CustomError::Standard` variant.
impl CustomError {
    /// If this variant holds a standard error created from a custom error,
    /// return the original custom error.
    pub fn downcast(self) -> Option<Self> {
        match self {
            CustomError::Standard(StandardError::Custom(maybe_custom)) => {
                maybe_custom.downcast::<CustomError>().ok().map(|b| *b)
            }
            _ => None,
        }
    }
}

let original_custom = erased_custom.downcast().unwrap();
assert!(matches!(original_custom, CustomError::MyError));

So I'm leaning more towards option 2 as if it should be more ergonomic to use even if as a result contracts won't share the same error type which could make cross-contract calls more tedious. We could provide adding the Standard variant to CustomError and the conversion in option 2 in a macro to make the custom error types more consistent.

A third option would be to promote option 1 in methods that should be called externally and option 2 in internal methods. But maybe this just leads to more confusion.

I'm thinking more about the specific implementation questions, just wanted to write this down.

@uint
Copy link
Contributor

uint commented Jun 30, 2023

@agostbiro I feel like the "standarized errors" bit in the title created a little confusion here. I don't think @itegulov meant standard errors (like HTTP error codes you mentioned), but a "standardized way of declaring contract errors" (sic!).

I'd vote for just having custom errors and not having standard errors - at least not at the SDK level.

Prescribed HTTP error codes were created because in principle, the HTTP design was all about fetching/posting/updating documents and other resources, with predefined operations for those purposes. It was never meant for custom apps with a tight client-server coupling like we see today. I don't think this is good inspiration for us.

In turn, our system is for apps with mostly a tight client-server coupling. Each app has its own set of operations and problems, and app devs should define all those themselves. It's closer to how RPC systems work, and every RPC system I've ever seen lets you define your app-specific errors without imposing any.

I think the question of how we compose sets of errors is very valid though. Errors like NotTheOwner could be provided by an access control lib, and then a dev creating a concrete contract would benefit from having a convenient way of including those AC errors.

@agostbiro
Copy link
Contributor

Thanks for elaborating @uint !

I meant to expand a scope a bit, because I think it'd be very useful to have standardized errors for common scenarios. For example, if a method call fails because of insufficient storage balance, it'd helpful to the caller (let that be a test, a contract, a wallet, a bot or whatever) to have a catchall that can detect that no matter what contract is called. The Ethereum ecosystem is moving into this direction with EIP-6093 as well.

In turn, our system is for apps with mostly a tight client-server coupling. Each app has its own set of operations and problems, and app devs should define all those themselves. It's closer to how RPC systems work, and every RPC system I've ever seen lets you define your app-specific errors without imposing any.

I like how JSON-RPC handles this by defining a few standard errors and reserving error codes for implementation defined errors. While it's somewhat controversial, this practice has been expanded to HTTP status codes as well.

@uint
Copy link
Contributor

uint commented Jun 30, 2023

For example, if a method call fails because of insufficient storage balance

I could be very wrong here, so someone correct me if I am.

I suspect insufficient storage balance would raise HostError::BalanceExceeded in the runtime. I imagine tests and clients can catch that and handle it. It doesn't look like that error would be propagated in a cross-contract call, but I'm sure that could be fixed.

The Ethereum ecosystem is moving into this direction with EIP-6093 as well.

I'm not sure I understand. The linked EIP seems to be about introducing some custom contract errors as a standard, not as part of an SDK or runtime. It also doesn't seem to involve universal smart contract errors (like running out of gas/storage money), but ones strictly tied to their token standards.

@agostbiro
Copy link
Contributor

agostbiro commented Jul 3, 2023

I could be very wrong here, so someone correct me if I am.

I suspect insufficient storage balance would raise HostError::BalanceExceeded in the runtime. I imagine tests and clients can catch that and handle it. It doesn't look like that error would be propagated in a cross-contract call, but I'm sure that could be fixed.

My understanding is that the NEAR runtime raises an error if the contract's balance is too low, but if the contract implements storage management to pass storage costs onto users, then the contract will panic with some string message. Examples:

Fungible token:

"The attached deposit is less than the minimum storage balance"

"The amount is greater than the available storage balance"

Social DB

"Not enough storage balance"

"The attached deposit is less than the minimum storage balance"

"The amount is greater than the available storage balance"

Note that

"Not enough storage balance"

and

"The attached deposit is less than the minimum storage balance"

should be probably the same error type as the user has to increase their storage deposit in both cases. While "The amount is greater than the available storage balance" message should be a different error type since in this case the user tries to withdraw more than what they've put in.

Now the question remains, should these be part of some global error type provided by the SDK or should the error types live in the storage management trait? I think we should approach that question from what's more convenient to the caller.

I'm not sure I understand. The linked EIP seems to be about introducing some custom contract errors as a standard, not as part of an SDK or runtime. It also doesn't seem to involve universal smart contract errors (like running out of gas/storage money), but ones strictly tied to their token standards.

The errors defined in the EIP originated in the OpenZeppelin contract library. Ethereum has no storage staking, and most Ethereum apps operate in terms of tokens (e.g. follows are NFTs in Lens), so these errors are as generic as it gets in Ethereum smart contracts. :)

@uint
Copy link
Contributor

uint commented Jul 3, 2023

My understanding is that the NEAR runtime raises an error if the contract's balance is too low, but if the contract implements storage management to pass storage costs onto users, then the contract will panic with some string message.

Yeah, that makes sense.

Ethereum has no storage staking, and most Ethereum apps operate in terms of tokens (e.g. follows are NFTs in Lens), so these errors are as generic as it gets in Ethereum smart contracts. :)

I don't agree that it's a question of how many contracts implement these interfaces. To me, it's a question of if we're talking about errors that are tied to the runtime/SDK logic, or if we're talking about errors tied to the logic of a standard/interface/contract. I wouldn't want to conflate them. I was also definitely not implying Ethereum has storage staking.

Now the question remains, should these be part of some global error type provided by the SDK or should the error types live in the storage management trait? I think we should approach that question from what's more convenient to the caller.

Yes, good call. I'm fairly sure with the right design either option would be fine from the dev exp perspective, though I don't have a good feel for how much dev work we're looking at here. What I worry about more than convenience is managing complexity and separating concerns on our side, which is also crucial.

Having error types related to an interface live within the definition of that interface (rather than globally defined in the SDK) has these advantages:

  • Interfaces can evolve independently from the SDK. Changes to error types defined in interfaces won't require coordinating changes in the SDK.
  • We won't have to draw arbitrary lines around which error types do or do not make it into the SDK.
  • The error namespace in the SDK won't be as polluted, and the SDK will be more focused. Since it's already a complex piece of software, I'd argue it's great when we can avoid adding a new concern to it.

I haven't yet looked very deeply at how composability is handled when it comes to standards/interfaces. I'll take a look sometime soon.

For the record, I think you touched on this: I definitely agree when contracts deal with cross-contract calls, they should be able to reason about all possible errors, including ones that arise in the runtime or interfaces.

@frol
Copy link
Collaborator

frol commented Jul 5, 2023

@agostbiro @uint Thanks for thinking through it! Great ideas and I think we are all on the same page here, and the only thing I really want to mention here is that there are several problems at different levels we need to address:

  1. How to signal the errors on the low-level? (SDK-agnostic problem) - it will require coordination through writing down a NEP standard (we don't need to start there, we can do it after we experiment with it in near-sdk-rs)
  2. How to make custom errors easy to define? (I love thiserror, btw)
  3. Do we need to define some NEAR-wide type of contract errors? (I doubt)
  4. Do we define the NEP-specific errors in the near-contract-standards? (I cannot think of a reason why we wouldn't want to do that; existing contract standard NEPs lack error handling details, so near-sdk-rs should pave the road there)

@frol
Copy link
Collaborator

frol commented Jul 30, 2023

One more thing worth to mention in a separate comment is:

  1. How do we flag errors without reverting all the changes made during the execution of the function call (calling near_sdk::env::panic_str fails the receipt, and all the changes made during the execution will be rolled back)? We need both reverting and non-reverting error signaling (see relevant discussion here: NEP-452 Linkdrop Standard NEPs#452 (comment)) - see the follow up discussion here: Have #[handle_result] support Result types regardless of how they're referred to #1057 (comment)

@fadeevab
Copy link

fadeevab commented Aug 2, 2023

@frol @agostbiro @uint Let me drop my 5 cents into the conversation.

  1. Standard error enum variants are not really necessary.
    • There is no major request/traction from the community.
    • From my personal experience, after using the io::Result for a while, I feel it just ends up using the Custom in many situations, it's a subjective perception though.
  2. Persisting the state was a real use case according to the NEP-452 Linkdrop Standard NEPs#452 (comment) conversation.

I may be wrong with my assumption, and I am open to any evidence of the opposite :)

That said, I see an option to introduce an attribute, say, #[persist] allowing us to use it "in perpendicular" to other ways of error handling.

Thus, 4 variants:

  1. Panic ignoring the state:
pub fn method(&mut self) {
    panic!("Stereotypical error");
}
  1. Panic persisting the state:
#[persist]
pub fn method(&mut self) {
    self.state += 1;
    panic!("Error but state is saved");
}
  1. Handling Result ignoring the state:
#[handle_result]
pub fn method(&mut self) -> Result<(), MyError> {
    Err(MyError::SomethingWrong)
}
  1. Handling Result persisting the state:
#[persist]
#[handle_result]
pub fn method(&mut self) -> Result<(), MyError> {
    self.state+= 1;
    Err(MyError::SomethingWrong)
}

And I haven't checked if it's technically feasible, "request for comments".

@frol
Copy link
Collaborator

frol commented Aug 3, 2023

@fadeevab When you take a step back and think how it looks from a perspective when you are not laser-focused on error handling, it just feels odd:

#[persist]
fn inc(&mut self) {
    self.counter += 1;
}

Would it mean that without #[persist] it would not save/persist anything?

Other points:

  1. Adding more attributes makes it even harder for users to learn the tech. Less is more.
  2. panic is not the right way to signal errors in Rust world.
  3. panic is not designed to help with structuring errors, and I don't think we should promote unstructured errors going forward, so having proper infrastructure for Result handling will be the best way forward, I believe.

That said, I don't see a reason to introduce even more magic into error handling instead of actually making it simpler.

@fadeevab
Copy link

fadeevab commented Aug 4, 2023

@frol Agree. Then, what's the option to signalize an error with a saved state: some dedicated error variant?

// Pseudo code
enum Error {
    RollbackError(String),
    StoreStateError(String),
}

@robert-zaremba
Copy link
Contributor

The key part here is the structured errors.

Parsing error strings / panic messages should be gone long time ago.

@robert-zaremba
Copy link
Contributor

Ideally we don't introduce breaking changes. @fadeevab , I think we don't need more macros.
#[handle_result] already cancels the state changes (Personally I would rename it, but I'm not sure if it's worth to do a breaking change). If it's not present, then the state changes will persist.

@PolyProgrammist
Copy link
Contributor

PolyProgrammist commented Mar 31, 2024

Hi! I would like to work on this issue. Discussed it with @frol.

I see 3 different tasks for now:

  1. If the method in the implementation returns Result<ReturnType, SomeError>, and SomeError implements ContractError, and there is no #[handle_result], then inspect the result. ContractError will keep an actual error as a field. Introduce a function contract_error(t: T) for the user to create ContractError. If the method returns ReturnType, proceed as usual with value_return. If it returns an error, serialize it in panic_msg. Let's assume it will be serialized as JSON: {"error": t}. The error can be anything, including structured.

So the user code was like this previously:

#[near]
impl Adder {
    /// Adds two pairs point-wise.
    pub fn add(&self, a: Pair, b: Pair) -> Pair {
        sum_pair(&a, &b)
    }
}

Now it will be like this:

#[derive(ContractError)]
enum InvalidArguments {
    msg: string
}

#[near]
impl Adder {
    /// Adds two pairs point-wise.
    pub fn my_add(&self, a: Pair, b: Pair) -> Result<Pair, InvalidArguments> {
        if (b.0 == 0 && b.1 == 0) {
            contractError(InvalidArguments("b can't be equal to zero")
        }
        Ok(sum_pair(&a, &b))
    }
}
  • Ok() - It will be expanded to call host-function return_value(serde_json::to_string(T)), but in a more API-like way:
{
  "status": "success",
  "data": {
    "balance": 10
  }
}
  • Err(E) - It will be expanded to call host-function panic_str(serde_json::to_string(json!{"error": E}))
  • If there is a call to panick in the contract, there will be a compilation error
  1. As for linkdrop case, add the ability to save data if necessary after the error. I would also consider implementing this at the Near Core level.

If you add #[persist_on_error] macro, the behaviour will be different:

#[near]
impl Adder {
    /// Adds two pairs point-wise.
    #[persist_on_error]
    pub fn my_add(&self, a: Pair, b: Pair) -> Result<Pair, InvalidArguments> {
        if (b.0 == 0 && b.1 == 0) {
            Err(InvalidArguments("b can't be equal to zero"))
        }
        Ok(sum_pair(&a, &b))
    }
}
  • In this case, panic will never be called
  • If either error, Ok(T) returned, call return_value(serde_json::to_string(Result(...))), but in a more API-like way:
{
  "status": "success",
  "data": {
    "balance": 10
  }
}

{
  "status": "error",
  "error": {
    "kind": "ACCOUNT_DOES_NOT_EXIST",
    "data": {
      "account_id": "qwe"
    }
  }
}
  1. Create a set of standard errors (this can be done in this or a separate repository), and utilize them in near-sdk-rs. Add this to near-standard-contracts and examples.

It seems like that this does not make anything existing incompatible, including contracts, explorers, etc. Additionally, with the first point, it will be possible to update contracts using Result<ReturnType, ContractError<T>> without breaking anything.

Furthermore, all these three things can be documented in one NEP.

Also, there is a problem that we should not forget: currently, when you call rpc, and contract panics, it does not return a structured error which would say that the contract panicked. However it returns some string representation of that. Which is subject to fix as well, otherwise this task does not make any sense.

Naming is going to be discussed more.

I would like to start with the first two ones.

So what do you guys think of this?

@austinabell hi, do you have any comments on this?

@fadeevab
Copy link

fadeevab commented Apr 1, 2024

@PolyProgrammist I wonder, is it possible to simplify the return type to Result<ReturnType, T>? If it's an Err variant of a Result then it's already an error. And it still works: "if there is no #[handle_result], then inspect the result"

@PolyProgrammist
Copy link
Contributor

@PolyProgrammist I wonder, is it possible to simplify the return type to Result<ReturnType, T>? If it's an Err variant of a Result then it's already an error. And it still works: "if there is no #[handle_result], then inspect the result"

There are some contracts that already use Result<ReturnType, T> and if we are going to panic on error, their behaviour is going to change

@fadeevab
Copy link

fadeevab commented Apr 2, 2024

@PolyProgrammist I see...

What if instead of hardcoding the struct ContractError<T> type somewhere in NEAR SDK, a trait ContractError is defined, and custom user errors would derive/impl it?

#[derive(ContractError)]
enum MyError {
    InvalidArguments
}

pub fn my_add(&self, a: Pair, b: Pair) -> Result<Pair, MyError> {
    Err(MyError::InvalidAgruments)
}
  1. It usually works perfectly with error boxing with automatic conversion (playground):

    fn main() -> Result<(), Box<dyn ContractError>> {}
  2. Additional information I found about trait checking in runtime: https://stackoverflow.com/questions/30274091/is-it-possible-to-check-if-an-object-implements-a-trait-at-runtime

@PolyProgrammist
Copy link
Contributor

@PolyProgrammist I see...

What if instead of hardcoding the struct ContractError<T> type somewhere in NEAR SDK, a trait ContractError is defined, and custom user errors would derive/impl it?

#[derive(ContractError)]
enum MyError {
    InvalidArguments
}

pub fn my_add(&self, a: Pair, b: Pair) -> Result<Pair, MyError> {
    Err(MyError::InvalidAgruments)
}
  1. It usually works perfectly with error boxing with automatic conversion (playground):
    fn main() -> Result<(), Box<dyn ContractError>> {}
  2. Additional information I found about trait checking in runtime: https://stackoverflow.com/questions/30274091/is-it-possible-to-check-if-an-object-implements-a-trait-at-runtime

Sounds reasonable, thanks for pointing that out. Changed the comment

@PolyProgrammist PolyProgrammist linked a pull request Apr 8, 2024 that will close this issue
@PolyProgrammist
Copy link
Contributor

Hey, so the things changed a bit:

As Vlad suggested, for persisting on error, we can write state in current chunk and create a new contract call so that error is in another chunk in panic_str.

And in this case error is never in status but always in panic_str and therefore it's better to not use "status" field so that things remain as before.

@PolyProgrammist
Copy link
Contributor

PolyProgrammist commented May 13, 2024

@frol @fadeevab hey so do I implement From<Error> for Box<...>?
And, at least for this feature to work, I need to make all the objects that implement ContractError trait to implement Serialize and Deserialize.

@PolyProgrammist
Copy link
Contributor

PolyProgrammist commented May 13, 2024

@frol @fadeevab so I actually don't understand how do I implement the Box<...> thing. In order to do that, the ContractError should be serializable with serde. However if I define trait ContractError: serde::Serialize + ... {}, I bump into "the trait ContractError can't be made into an object" error as soon as Box<dyn ContractError + 'a> has dyn ContractError which is trait object, but ContractError is not object-safe in this case under "Not have any type parameters" as soon as it's supertrait Serialize has a method with type parameter: fn serialize<S>(...

@frol
Copy link
Collaborator

frol commented May 20, 2024

@PolyProgrammist I don't understand the problem. Could you provide the "expanded" code snippet of how you want it to be and what does not work?

@PolyProgrammist
Copy link
Contributor

@frol

So, here is what is happening:

As @fadeevab mentions here, the user may want to use:

fn main() -> Result<(), Box<dyn ContractError>> {}

So that the error which implements ContractError trait can be converted automatically to a common type. In order to achieve that, as soon as it is result of a method of NEAR contract, it should be JSON serializable.

This code tries to implement that:

// --- NEAR SDK ---
trait ContractError: Debug + Display + serde::Serialize {}

// https://doc.rust-lang.org/src/alloc/boxed.rs.html#2215
impl<'a, E: ContractError + 'a> From<E> for Box<dyn ContractError + 'a> {
    fn from(err: E) -> Box<dyn ContractError + 'a> {
        Box::new(err)
    }
}

// --- Contract ---
use std::fmt::{Debug, Display, self};

#[derive(Debug)]
enum MyError {
    Zeroes,
    Overflow,
}

impl Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "this is my error")
    }
}

impl ContractError for MyError {}

fn add(a: u32, b: u32) -> Result<u32, MyError> {
    if a == 0 && b == 0 {
        return Err(MyError::Zeroes);
    }
    
    if a == 0xffffffff || b == 0xffffffff {
        return Err(MyError::Overflow);
    }

    Ok(a + b)
}


fn main() -> Result<(), Box<dyn ContractError>> {
    let sum = add(1, 2)?; // Ok
    println!("{sum}");
    let sum = add(0, 0)?; // Throws an error
    println!("{sum}");
    Ok(())
}

But it does not compile:

Compiling playground v0.0.1 (/playground)
error[E0038]: the trait `ContractError` cannot be made into an object
   --> src/main.rs:5:45
    |
5   | impl<'a, E: ContractError + 'a> From<E> for Box<dyn ContractError + 'a> {
    |                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `ContractError` cannot be made into an object
    |
note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>
   --> /playground/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde-1.0.199/src/ser/mod.rs:249:8
    |
249 |     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    |        ^^^^^^^^^ ...because method `serialize` has generic type parameters
    |
   ::: src/main.rs:2:7
    |
2   | trait ContractError: Debug + Display + serde::Serialize {}
    |       ------------- this trait cannot be made into an object...
    = help: consider moving `serialize` to another trait
    = help: only type `MyError` implements the trait, consider using it directly instead

Why is that? The error "the trait ContractError can't be made into an object" arises as soon as Box<dyn ContractError + 'a> has dyn ContractError which is trait object, but ContractError is not object-safe in this case under "Not have any type parameters" as soon as it's supertrait Serialize has a method with type parameter: fn serialize<S>(...


So the question is, how do I make this code work:

fn main() -> Result<(), Box<dyn ContractError>> {}

@PolyProgrammist
Copy link
Contributor

So what if instead I do this:

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=96b08a7fe01cbbe224ed0ff5ed8052c1

// --- NEAR SDK ---
use serde::Serialize;

// near sdk
#[derive(Serialize, Debug)]
struct BaseError {
    error: String,
}

// near sdk
trait ContractError {
    fn to_string(&self) -> &String;
}

// user defined
// #[contract_error]
// struct MyError1 {
//    err: String,
// }

// generated by #[contract_error]
struct MyError1 {
    error: String
}

impl ContractError for MyError1 {
    fn to_string(&self) -> &String {
        return &self.error;
    }
}

impl From<MyError1> for BaseError {
    fn from(err: MyError1) -> Self { 
        BaseError{
            error: err.to_string().clone(),
        }
    }
}

fn add(a: u32, b: u32) -> Result<u32, MyError1> {
    if a == 0 && b == 0 {
        return Err(MyError1 {error: String::from("error 1")});
    }
    
    if a == 0xffffffff || b == 0xffffffff {
        return Err(MyError1 {error: String::from("error 2")});
    }

    Ok(a + b)
}


fn main() -> Result<(), BaseError> {
    let sum = add(1, 2)?; // Ok
    println!("{sum}");
    let sum = add(0, 0)?; // Throws an error
    println!("{sum}");
    Ok(())
}

@fadeevab
Copy link

@PolyProgrammist I got it!

You found that the following snippet is not sufficient for the smart contract because MyError must also be Serialize:

// Error
#[derive(ContractError)]
enum MyError {
    InvalidArguments
}

Thus, you consider 2 options:

Option 1:

#[derive(ContractError, Serialize)]
enum MyError {
    InvalidArguments
}

Option 2:

#[contract_error]
enum MyError {
    InvalidArguments
}

The following is NOT an option:

trait ContractError: serde::Serialize {}

cause the error type itself (MyError) must derive the Serialize trait.


The bottom line: #[contract_error] is probably the most universal way to go. You should be able to unfold it as follows:

#[derive(ContractError, Serialize)]
enum MyError {
    InvalidArguments
}

It should allow you to get rid of the BaseError type.


Is it correct?

@PolyProgrammist
Copy link
Contributor

PolyProgrammist commented May 22, 2024

@fadeevab but for the method:

fn main() -> Result<(), Box<dyn ContractError>>

to be usable, Box<dyn ContractError> should implement Serialize. How do you suggest to support that?

@fadeevab
Copy link

@PolyProgrammist Oh, you're right, the Box itself must be serializable...

It means you have the only option: yours :) (what I called "option 2").

I think it's okay as long as it looks like the following snippet for the end user:

#[contract_error]
enum MyError {
    InvalidArguments
}

...because the end user doesn't see the fn main() part.

@fadeevab
Copy link

fadeevab commented May 23, 2024

@PolyProgrammist Theoretically, there should be also an "option 3" where the ContractError provides sufficient trait API (e.g. description, error_code, etc.) which would allow "unpacking" error data from the Box "manually" and serializing error data into the BaseError type. It would allow having the following notation

#[derive(ContractError)]
enum MyError {
    InvalidArguments
}

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

Successfully merging a pull request may close this issue.

7 participants