Skip to content

Support Option<T> as a first-class ABI type for method inputs and returns #81

@charlesHetterich

Description

@charlesHetterich

The new pvm-contract-sdk ABI traits/macros do not currently support Rust
Option<T> as a contract method input or return type.

T itself can implement SolEncode / SolDecode, including dynamically
encoded structs when the contract uses an allocator, but Option<T> does not
appear to have corresponding ABI support. The result is that method dispatch
cannot encode returned Option<T> values, and imported callers cannot decode
them.

Observed on branch sm/cdm at 059bb9ee.

Current behavior

There are no SolEncode, SolDecode, StaticEncodedLen, or StaticDecode
implementations for Option<T> in pvm-contract-types.

That means this kind of contract surface does not compile:

#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)]

extern crate alloc;

use alloc::string::String;

#[derive(Default, pvm_contract_sdk::SolType)]
pub struct Profile {
    pub id: u64,
    pub metadata_uri: String,
}

#[pvm_contract_sdk::contract(allocator = "bump")]
mod example {
    use super::Profile;
    use alloc::string::String;

    pub struct Example;

    impl Example {
        #[pvm_contract_sdk::constructor]
        pub fn new(&mut self) {}

        #[pvm_contract_sdk::method]
        pub fn maybe_id(&self, exists: bool) -> Option<u64> {
            if exists { Some(1) } else { None }
        }

        #[pvm_contract_sdk::method]
        pub fn maybe_profile(&self, exists: bool) -> Option<Profile> {
            if exists {
                Some(Profile {
                    id: 1,
                    metadata_uri: String::from("ipfs://example"),
                })
            } else {
                None
            }
        }
    }
}

The expected failure mode is a trait bound error along the lines of
Option<u64>: SolEncode / Option<Profile>: SolEncode not being satisfied.
The same issue applies on the caller side for imported ABIs, where return
types need SolDecode.

Why this matters

Several CDM contracts expose lookup APIs where absence is part of the contract:

pub fn get_profile_info(context_id: ContextId, profile_id: EntityId) -> Option<Profile>
pub fn get_profile_at(context_id: ContextId, owner: Address, index: u32) -> Option<EntityId>
pub fn get_post(context_id: ContextId, post_id: EntityId) -> Option<Post>
pub fn get_parent_at(context_id: ContextId, parent: EntityId, index: u32) -> Option<EntityId>

Some of these are static values (Option<EntityId>), and some are dynamic
values (Option<Profile>, Option<Post>) where the inner type includes
String or Vec<T>. Dynamic returns themselves are supported with an
allocator; the missing piece is the optional wrapper.

The CDM registry-style API also naturally wants this shape:

pub fn get_address(contract_name: String) -> Option<Address>

Returning a sentinel value such as zero address or zero id is less precise and
pushes absence semantics into every downstream caller.

Requested behavior

Please add an official ABI convention for Option<T> and implement it across:

  • SolEncode for Option<T>
  • SolDecode for Option<T>
  • StaticEncodedLen / StaticDecode where T is statically encoded
  • contract method dispatch return encoding
  • method argument decoding
  • abi_import! generated caller bindings
  • ABI JSON generation, if applicable

A reasonable convention would be to encode Option<T> as a tuple:

(bool is_some, T value)

Under this convention:

  • Some(value) encodes as (true, value).
  • None encodes as (false, default_or_zero_value_for_T).
  • Decoding may ignore value when is_some == false, while still requiring
    well-formed ABI bytes.
  • Option<T> is static when T is static, with encoded size
    32 + T::ENCODED_SIZE.
  • Option<T> is dynamic when T is dynamic.

If the SDK should not choose a default/zero value for None, another viable
design would be to require T: Default for SolEncode<Option<T>>, or to add a
dedicated trait for ABI-default values. The important part is that the SDK
should define one convention so contract authors and generated callers agree.

Expected result

Contracts should be able to write:

#[pvm_contract_sdk::method]
pub fn get_profile(&self, id: u64) -> Option<Profile> {
    // ...
}

and imported callers should be able to call the generated binding as:

let maybe_profile: Option<Profile> = profiles
    .get_profile(id)
    .call(self)?;

without each contract having to define custom MaybeProfile,
MaybeAddress, MaybeEntityId, etc. wrapper structs by hand.

Workaround today

Contracts can manually define wrapper structs:

#[derive(Default, pvm_contract_sdk::SolType)]
pub struct MaybeProfile {
    pub exists: bool,
    pub value: Profile,
}

This compiles if Profile itself is ABI-compatible, but it loses the natural
Rust API shape, creates one wrapper per optional type, and forces every caller
to remember the same ad hoc convention.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions