Representing pallet's origins as System-like origins

This post discusses whether a potential solution should enable granular control over representing/casting/converting a pallet origin into a system-wide origin.

Problem Statement

Built-in guards and UX

Traditionally, pallet builders have used built-in checks (like ensure_signed, ensure_root, or ensure_none) to implement guards on permissionless methods. The reasons behind this decision are pretty simple to explain:

  1. Checks are already there.
  2. They are simple, ergonomic, and intuitive. They provide the minimum essential guards without the need to think too much about it.
  3. Implementing custom guards (EnsureOrigin, custom implementations involving OuterOrigins) are usually not necessary unless privileged calls/pallet origins come into place.

The need for more flexibility

While this isn’t necessarily problematic for most users (especially when pallet developers would expose their intended public APIs via traits), some edge cases require more flexibility.

One of those edge cases is where an arbitrary call is needed to be executed, either via the Proxy or Schedule pallets or directly using Dispatchables. An example of this case is what’s implemented on Collective or Referenda pallets, and more recently on Communities pallet (currently under development).

Implementing such solutions requires designers to think about clean implementations that are limited to the scope of the pallet they’re building or, at least, to give runtime implementors some degree of manageability on which call origins may be used when dispatching an arbitrary call. This is where workarounds and tricks bloom.

Workarounds

For example, while Collectives is opinionated on which origin to use when executing or proposing a poll and is therefore limited to the ensures that might be implemented as valid origins for some pallets, Referenda exposes the TracksInfo trait to let runtime implementors define whether an origin is bound to a track. This limits the ability of referenda proposals to what the runtime implementors decide. Also, it is less ergonomic, as it doesn’t allow for default behavior.

Converting a pallet origin into system origin is not possible (or at least, not that easy)

In the specific case of Communities, the initially designed approach was to let a pallet origin be expressed in terms of frame_system’s Origin, as to be signed by the community’s AccountId, without intervention from the runtime end or without needing explicitly stating the origin to dispatching the call of the proposal, once approved, so it’s possible to execute some calls that don’t implement custom ensures (such as system::remark_with_event).

However, this is not possible by default due to the current constraints of how FRAME is built, and the workaround was to follow a similar approach to what’s presented on the Referendum pallet with TracksInfo.

While this is not that bad (in the end, it’s a workaround and it works), it poses some challenges:

  1. Reduces ergonomy since it forces the users of the pallet to declare the signed origin for their community AccountId.
  2. If a builder is unaware of these restrictions or how to circumvent them, it increases potential points of failure (or, better, points for attacks — let’s not forget how powerful a dispatchable is).
  3. While it is possible to implement a solution where a pallet origin defines a conversion to a system origin on their end, which is considered by the RuntimeOrigin, this requires implementing a custom RuntimeOrigin, not the one provided by FRAME. This implies redoing more boilerplate code, degrading the DX.

Solution

It is theoretically possible to implement a retro-compatible (thus, no need for a major breaking change) solution on FRAME, that enables pallet builders to define a pallet’s origin conversion to system Origin that is considered when building RuntimeOrigin.

An example of this potential solution goes like this:

// Define the structure for the pallet origin

pub struct RawOrigin {}

// Implement a failable conversion to `frame_syetem::Origin`

impl<T> trait TryInto<frame_system::Origin<T>> for RawOrigin 
  where
    T: frame_system::Config
{
  type Error = ();

  fn try_into (self) -> Result<frame_system::Origin<T>, ()> {
    todo!("Implement conversion to system origin here")
  }
},

// Declare the structure as pallet origin

#[pallet::origin(as_system_origin)]
pub type Origin = RawOrigin;

This would tell FRAME to consider the origins coming from that pallet as possible candidates for an attempted conversion. An approach like this:

impl From<RuntimeOrigin> for #scrate::__private::sp_std::result::Result<#system_path::Origin<#runtime>, RuntimeOrigin> {
	/// NOTE: converting to pallet origin loses the origin filter information.
	fn from(val: RuntimeOrigin) -> Self {
		match val.caller {
			OriginCaller::system(l) => Ok(l),
			// for #variant_name in #system_convertible_variants
			OriginCaller::#variant_name(x) => x.try_into().map_err(|_| val),
			// /for #variant_name in #system_convertible_variants
			_ => Err(val)
		}
	}
}

Discussion

Of course, a post like this wouldn’t be complete without an invitation to leave feedback and think of the possibilities for this.

1 Like

Sorry, but I do not quite get the intended goal here.

You want to be able to create some guarantee that a RuntimeOrigin (which is the accumulation of all origins in your whole runtime), can always be converted to a SystemOrigin, which is one of: Signed, None, Root.

Is that right?

Would this basically a way to give each Pallet their own account id which they can use to created signed origins?

Or what is the goal you are try to achieve specifically?

Perhaps could you write the most minimal before and after code which your idea will solve?

I don’t really understand the problem that you are trying to solve. Some specific examples?

Not exactly, but something more like it.

The intended goal would be to enable a mechanism where a pallet can dispatch calls using their own origin and that this origin can be interpreted as a system origin (either Signed, Root, or None).

Sample

My pallet has an origin. The origin follows this structure:

struct RawOrigin<CommunityId, VoteWeight> {
  community_id: CommunityId,
  body_part: BodyPart<VoteWeight>,
}

There’s a method in the pallet that allows executing arbitrary calls on behalf of the community using an instance of this origin. To avoid the complexity of handling weights, we schedule the calls. The scheduling looks something like this:

T::Scheduler::schedule(
	DispatchTime::After(Zero::zero()),
	None,
	Default::default(),
	proposal.origin,
	proposal.call,
)

Where proposal.origin is an instance of our pallet’s origin, with a specific community_id. The idea is that when scheduling a call that checks for ensure_signed, we should implement a conversion that looks like this:

impl<T: Config> TryInto<frame_system::Origin<T>> for pallet::Origin<T> {
	type Error = ();

	fn try_into(self) -> Result<frame_system::Origin<T>, Self::Error> {
		let community_account_id = Pallet::<T>::get_community_account_id(&self.community_id);
		Ok(frame_system::Origin::Signed(community_account_id))
	}
}

And that should be enough for a ensure_signed to pass, returning that community_account_id (that uses PalletId::<T>::into_sub_account internally) as the success value.

The expected behaviour would be that if we call a permissionless method that uses ensure_signed (such as system::remark_with_event), it executes successfully, and an event is emitted.

What currently happens is that it’s not possible to directly cast the pallet::Origin as frame_system::Origin: this is because of when ensure_signed calls for origin.into() internally, what RuntimeOrigin does is to check whether there’s an instance of OriginCaller::system. Any other variant would be immediately rejected.

So, the example I set in the original post (calling system::remark_with_event, would fail to return a BadOrigin error for this design.

By applying this change, I propose, this cast should be possible to occur.

So it’s not about implementing a default signed origin for pallet’s origins (as the pallet’s account), but letting pallet builders to implement their own conversion).

Why must this overhead be brought into frame system.

If you intend to have the logic which is:

let community_account_id = Pallet::<T>::get_community_account_id(&self.community_id);
Ok(frame_system::Origin::Signed(community_account_id))

What is the concern with just dispatching from this pallet with Signed(community_account_id)?

It seems to me all of this could be solved by adding a single extrinsic into your pallet:

#[pallet::call]
pub fn convert_community_to_signed_call(origin: OriginFor<T>, call: Box<RuntimeCall>) -> DispatchResult {
    let community_account_id = ensure_community_and_get_account(origin)?;
    call.dispatch(frame_system::RawOrigin::Signed(community_account_id)?
}

Then users simply can dispatch their community origin calls through this function to generate a signed origin call. In this case, there is less “magic” and you are not pushing further requirements or complexity up the FRAME stack.

For example, from a types perspective, it is not obvious to me that most origins should be convertible to a frame_system::RawOrigin. This is probably why I didn’t understand your post to begin with. Having the origin represent a community, and that community having an account id seems very specific to certain scenarios, and ones you can support directly in your pallet.