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

Proposal: General approval pattern (for modular systems and session wallets) #327

Closed
alvrs opened this issue Jan 6, 2023 · 22 comments
Closed
Labels
discussion A discussion (without clear action/outcome)

Comments

@alvrs
Copy link
Member

alvrs commented Jan 6, 2023

Note: I’ll use keccak(a,b,c) in this proposal to refer to keccak256(abi.encode(a,b,c))

Problem description

This proposal is a solution for multiple problems:

  1. Session wallets:
    Currently, we mainly use burner wallets to reduce the friction of manually approving each transaction. However, this approach is not suitable for storing valuable or permanent assets because the private key of the burner wallet is stored in the browser's local storage and is therefore vulnerable to phishing or loss. The current alternative is to use regular wallets such as MetaMask, which drastically increases the friction of using tx-heavy applications because each tx must be approved. We need a way to store valuable and permanent assets in a main wallet, but interact with the application via a temporary session wallet.
  2. Modular systems:
    Described in more detail in Modular systems / system to system calls #319. Short summary: Currently, systems can only serve as an entry point into the application, but it is inconvenient to use systems as stateful libraries or modules to build other higher level systems.

Proposal description

On a high level, the idea is to create a mechanism to allow any address to approve any other address to call certain systems on its behalf.

It is inspired by ERC20’s approve / transferFrom pattern, @dk1a’s Subsystem proposal (#319 (comment) / #268) and @dk1a's comment about approvals here: #319 (comment)

Changes to World contract

We add an ApprovalComponent and ApprovalSystem to the MUD World contract.

  • ApprovalComponent

    • EntityID: keccak(grantor, grantee) (for generic approvals) or keccak(grantor, grantee, systemID) (for approvals restricted to certain systems)

    • Value: Approval

      struct Approval {
         uint128 expiryTimestamp;
         uint128 numCalls;
         bytes args;
      }
      • (Side note: To save gas when verifying approvals, expiryTimestamp and numCalls could be bitpacked into a single storage slot. To save even more gas, one bit in this bitpacked slot could be reserved as a flag to represent whether the args value is empty or non-empty.)
  • ApprovalSystem

    • Has write access to ApprovalComponent
    • Functions:
      • setApproval(address grantee, uint256 systemID, Approval memory approval)

        • Creates a new approval from msg.sender as grantor to grantee. To create a generic approval, systemID can be set to 0.
      • reduceApproval(address grantor, address grantee, bytes args)

        • Throws if no valid approval is found, reduces numCalls if applicable (see below pseudo code for details)

          function reduceApproval(address grantor, address grantee, bytes memory args) public {
          	// Check for a generic approval
          	(Approval memory approval, bool found) = getApproval(grantor, grantee);
          	
          	// Return successfully if a valid approval is found
          	if(found && approval.expiryTimestamp > block.timestamp) return;
          
          	// Check for a specific approval
          	uint256 systemID = getIdByAddress(msg.sender); // Only the approved system can reduce the approval's numCalls value
          	(approval, found) = getApproval(grantor, grantee, systemID);
          
          	// Trow if no approval is found
          	require(found, "no approval");
          
          	// Throw if expiry timestamp is in the past
          	require(approval.expiryTimestamp > block.timestamp, "approval expired");
          
          	// Throw if numCalls is 0
          	require(approval.numCalls > 0, "no approved calls left");
          
          	// Throw if args exists and doesn't match approved args
          	require(approval.args.length == 0 || approval.args == args, "args not approved");
          
          	// Reduce numCalls, unless it's MAX_UINT128 to support permanent approvals
          	if(approval.numCalls != MAX_UINT128) {
          	     approval.numCalls--;
          	     setApproval(grantor, grantee, approval);
          	}
          }
      • revokeApproval(address grantee) / revokeApproval(address grantee, uint256 systemID)

        • Removes the approval for the given grantee / systemID
      • More functions like checkApproval can be added, but are less relevant for this proposal

Changes to Systems

Every system implements its logic in an internal _execute(address from, bytes memory args) function.

The base System contract implements the following functions:

  • execute(bytes memory args): executes the system as msg.sender

    function execute(bytes memory args) public {
    	return _execute(msg.sender, args);
    }
  • executeFrom(address from, bytes memory args): checks if msg.sender has approval from from to call the system, then executes the system as from

    function executeFrom(address from, bytes memory args) public {
    	ApprovalSystem.reduceApproval(from, msg.sender, args);
    	return _execute(from, args);
    }
  • executeInternal(address from, bytes memory args): checks if msg.sender has approval from address(this) to call this system. If so, the call is trusted and the system is executed as from

    function executeInternal(address from, bytes memory args) public {
    	ApprovalSystem.reduceApproval(address(this), msg.sender, args);
    	return _execute(from, args);
    }

Applications

Session wallets

  • At the beginning of each session, the user gives a generic (or restricted) approval with expiryTimestamp to a burner wallet.
  • The client calls all systems via their executeFrom functions as the burner wallet, with from set to the main wallet. All assets are owned by the main wallet.
  • After the session, the main wallet can revoke the approval, or the approval runs out after the expiryTimestamp

Contract interaction

  • Similar to ERC20’s approve / transferFrom pattern, approvals can be used for atomic interactions with contracts, like
    1. User approves a contract to call a TransferSystem once with a certain argument to transfer ownership of a certain item to the contract.
    2. User calls the contract’s swap function, which calls TransferSystem on the user’s behalf to transfer the item to the contract, and then calls TransferSystem on its own behalf to transfer another item from the contract to the user. (Note how this is not possible if TransferSystem only uses msg.sender for access control.)

First party modular systems / using systems as stateful libraries

  • Developer wants to modularize movement logic into a MoveSystem, and use it as building block in a PathSystem and CombatSystem (since it’s the same developer, there is full trust in PathSystem and CombatSystem)
  • MoveSystem can create a permanent generic approval for PathSystem and CombatSystem, to allow both to call MoveSystem's executeInternal function. (Just like a library, except component access control happens on the level of MoveSystem instead of PathSystem/CombatSystem)
  • This use-case and usage is very similar to the approach explored in @dk1a’s subsystems Add subsystem #268
  • Creating these permanent generic approvals between first party systems can be automated via the MUD CLI / deploy process.

Third party modular systems

  • Assume a third party developer wants to create a new higher level system by combining multiple existing lower level systems.
  • They can use the lower level system’s executeFrom functions to do so.
    • This requires users to explicitly approve the higher level system to call the lower level systems on the user’s behalf (with optional restrictions), which is intended to prevent systems from being able to call any system on behalf of users.
    • The approval process can be a default client pop-up integrated into MUD.
  • Example:
    • There is a MoveSystem deployed by an unknown developer.
    • A third party developer wants to add a new PathSystem to automate pathfinding and travelling larger distances.
    • PathSystem declares MoveSystem as dependency, client automatically shows a pop up for user to give necessary permissions the first time user attempts to call PathSystem.
      • User calls RegisterSystem.setApproval(PathSystem, MoveSystemID) to allow the PathSystem to permanently (or with restrictions) call MoveSystem on user’s behalf.
    • From now on, PathSystem can call MoveSystem.executeFrom on behalf of the user to automate movement.
@alvrs alvrs added the discussion A discussion (without clear action/outcome) label Jan 6, 2023
@alvrs alvrs pinned this issue Jan 6, 2023
@dk1a
Copy link
Contributor

dk1a commented Jan 6, 2023

A really good proposal! Much better thought out than my vague approval concept, and very generalizable unlike subsystems.

For posterity: my comment about approvals was itself inspired by my OperatorApprovalComponent for ERC721/1155 Systems, so it all comes from ERC20 after all

Minor concern: permanent approvals can be impossible to revert if you forget who you approved it for (since you can neither reverse the hash nor loop through all addresses), but this can be solved via an extra component, or discouraging such approvals. (ERC20 etc solve this via events having the data - the extra component could hold such data instead)

executeInternal is special. Fully internal systems (subsystems) would want to disable execute and executeFrom, because address doesn't matter - (stateful) libs often operate on "ownerless" entities.
E.g. CombatSubsystem receives 2 entities:
1 of which can be an NPC (which has no owner at all),
and another kind of has an owner, but then that owner should never be able to call CombatSubsystem directly.

To that end I can still see a use case for a Subsystem, in the sense that allowing overrides of execute and executeFrom to disable them seems bad. I'm also not sure executeInternal is even needed in normal systems.
(and the Subsystem name still provides the benefit of working with it in cli easier, and readability of what's internal)
And if the Subsystem ends up existing separately anyways, it might easier to do it via OwnableWritable after all. But that's just an implementation detail at that point, I don't yet have an opinion either way

@dk1a
Copy link
Contributor

dk1a commented Jan 6, 2023

Just thought of another use-case for AbstractComponent from #267 while sketching some approval stuff.
You could use it to store structs in an optimal way, e.g.

contract ApprovalComponent is AbstractComponent {
  /** Mapping from entity id to value in this component */
  mapping(uint256 => Approval) internal entityToValue;

  constructor(address world, uint256 id) AbstractComponent(world, id) {}

  function getSchema() public pure override returns (string[] memory keys, LibTypes.SchemaValue[] memory values) {
    ...
  }

  function set(uint256 entity, Approval memory value) public virtual onlyOwner {
    _set(entity, value);
  }

  function _set(uint256 entity, bytes memory value) internal override {
    _set(entity, abi.decode(value, (Approval)));
  }

  function _set(uint256 entity, Approval memory value) internal virtual {
    // Store the entity's value;
    entityToValue[entity] = value;

    // Emit global event
    IWorld(world).registerComponentValueSet(entity, abi.encode(value));
  }

  function _remove(uint256 entity) internal virtual override {
    // Remove the entity from the mapping
    delete entityToValue[entity];

    // Emit global event
    IWorld(world).registerComponentValueRemoved(entity);
  }

  function has(uint256 entity) public view virtual override returns (bool) {
    return entityToValue[entity].numCalls != 0;
  }

  function getValue(uint256 entity) public view virtual returns (Approval memory) {
    return entityToValue[entity];
  }

  function getRawValue(uint256 entity) public view virtual override returns (bytes memory) {
    return abi.encode(entityToValue[entity]);
  }
}

This is sort of a bespoke alternative to bitpacking, and even more efficient in many cases (doesn't store length, which is 1 less slot; not sure how decode vs encode compares gas-wise).
(mud doesn't care much about gas atm, which is fine cause premature optimization is bad, but it becomes more meaningful down the line, especially for something pervasive like approval)

@dk1a
Copy link
Contributor

dk1a commented Jan 6, 2023

Also a note on overriding _execute:

Can't speak on general ApprovalSystem, which doesn't exist yet. In theory seems fine.

But for subsystems by now I actually dislike that part, in practice having no default execute seems more useful.
This is because my library-like systems tend to have several sub-executes that use similar dependencies, but are themselves unrelated.
E.g. CombatSubsystem has executeCombatRound, executeActivateCombat, executeDeactivateCombat. Neither of those 3 call each other, and the main execute just routes to the selected sub-execute. Having sub-execute -> execute -> internal sub-execute would just make it all way harder to read. And having 3 systems would be very inconvenient as well (it's not just CombatSubsystem, I'd get like 10 more subsystems)

Maybe a fully automated deployment would make having a ton of internal systems non-painful (#295 has some notes on why full automation is hard and unsolved)

@agostbiro
Copy link

Hi,

First time posting. I love this project and I've been following the issues. I'm very impressed by the quality of discussions here and this proposal in particular.

I wanted to share that I'm working on a better MetaMask that can do automated transaction approval in an EIP-1993 compatible way by creating a new key for each dapp that the user connects. It works the same way as burner wallets from the dapp perspective, but the keys are created and stored in the wallet app (instead of the page's JS context) and synced across the user's devices. You can find out more about how this works in the user docs and in the technical docs.

Obviously, it's not a solution for you problem today, just wanted to give you context that changes are to be expected in how wallets work in the future.

Best,
Agost

@dk1a
Copy link
Contributor

dk1a commented Jan 7, 2023

sealvault looks really useful.
It protects against malicious dapps in general,
whereas approval system protects against malicious third-party systems within a mud-based dapp.
To me the 2 actually seem complementary, as they solve different (though related) problems

@alvrs
Copy link
Member Author

alvrs commented Jan 9, 2023

permanent approvals can be impossible to revert if you forget who you approved it for
- #327 (comment)

Very good point! We could also account for this by storing this information in the Approval component itself:

struct Approval {
   uint128 expiryTimestamp;
   uint128 numCalls;
   bytes args;
   address grantor;
   address grantee;
   uint256 systemID;
}

The Approval component would probably be a BareComponent because the reverse mapping is not really useful in this case, but adding information about grantor, grantee and optional systemID to the struct would cause it to be emitted via the ComponentValueSet event, so it would be reconstructable.

executeInternal is special. Fully internal systems (subsystems) would want to disable execute and executeFrom, because address doesn't matter - (stateful) libs often operate on "ownerless" entities.
[...]
I'm also not sure executeInternal is even needed in normal systems.
- #327 (comment)

Also a note on overriding _execute:
Can't speak on general ApprovalSystem, which doesn't exist yet. In theory seems fine.
But for subsystems by now I actually dislike that part, in practice having no default execute seems more useful.
- #327 (comment)

Yeah, this is something I also thought about when drafting this proposal. The original idea behind requiring a default execute function in systems was to create dynamic UI on the client without requiring the system's ABI - but this was 1. never implemented and 2. by now I'm not convinced anymore that having a default execute method would be a good approach for that. The other second order advantage of having a default execute method is to encourage devs to split up logic into modular systems, but maybe that should be more or a convention than a rule. (In practice nothing prevents devs from registering any contract with any interface as "System" in the World contract anyway.) So in short, I'm all for removing execute as required from the System interface.

Instead, we could implement the access control checks described above in executeFrom and executeInternal as modifiers (eg onlyApproved, onlyApprovedByThis), and let devs add any number of xxxFrom / xxxInternal functions as needed. (And and make the functionFrom / functionInternal naming a convention rather than a requirement we can't enforce).

modifier onlyApproved(address grantor, bytes memory args) {
   ApprovalSystem.reduceApproval(grantor, msg.sender, args);
   _;
}

modifier onlyApprovedByThis(bytes memory args) {
   ApprovalSystem.reduceApproval(address(this), msg.sender, args);
   _;
}

To improve the experience for devs who want to use the execute pattern, we can add a ExecuteSystem that implements the _execute, execute, executeFrom, executeInternal functions using above modifiers with the spec described in the initial message (#327 (comment)).

@alvrs
Copy link
Member Author

alvrs commented Jan 9, 2023

I wanted to share that I'm working on a better MetaMask that can do automated transaction approval in an EIP-1993 compatible way
- #327 (comment)

Thanks for sharing this context! I agree with @dk1a that sealvault looks useful and is complementary to this issue.

@dk1a
Copy link
Contributor

dk1a commented Jan 9, 2023

We could also account for this by storing this information in the Approval component itself
#327 (comment)

The reason I leaned into a 2nd component initially was because that info is stored once, and then rarely used once to cancel. Whereas numCalls is used all the time. So it's just a gas tradeoff, doesn't really matter.

I'm all for removing execute as required from the System interface.
#327 (comment)

Haha you went farther than I meant, but I agree of course (I just meant the modifiers part of it, not removing execute from ISystem).
I can't think of any reason why many methods would be bad. I already actively do that in my systems. (#303 depends on execute, but it isn't even merged, and can be easily changed to support arbitrary methods)

This maybe also helps with something like #325, where execute gets in the way (are read-only systems better than FunctionComponent sometimes?)

To improve the experience for devs who want to use the execute pattern, we can add a ExecuteSystem that implements the _execute, execute, executeFrom, executeInternal functions

While I'm still skeptical about executeInternal there (it doesn't really hurt tho), it's totally worth it for the execute+executeFrom, those seem like a useful default to have in most places; the execute pattern just saves some boilerplate for 1-function systems.

Those comments are based on my project's current design:
All external systems are 1-function (would be execute+executeFrom in the new paradigm).
All internal systems are multi-function (would be multiple different executeInternals).
No systems are hybrid (i.e. none would need execute+executeFrom+executeInternal).

@Kooshaba
Copy link
Contributor

Kooshaba commented Jan 9, 2023

I have a very specific use-case that I am working through right now that requires this feature, so I'd like to explain the player journey to see if I understand everything correctly.

The simple explanation is that I need two systems that exist on separate MUD Worlds to be able to interact with each other and transfer information between them in a trusted way.

Here is the specific example of how Sky Strife would use this:
CleanShot 2023-01-09 at 13 09 19

Player Journey:

  • player registers for a Sky Strife match
  • during registration:
    • Approval is created for the Amalgema JoinMatchSystem to impersonate them (1 call)
  • player calls JoinMatchSystem, which calls MacroJoinMatchSystem and joins match
  • during join:
    • Approval is created for the Sky Strife ItemExtractionSystem to impersonate them (1 call)
  • player plays match and accumulates items
  • on match end, player calls ItemExtractionSystem, which calls MatchItemExtractionSystem, and transfers items to Amalgema

If I am understanding everything correctly, this proposal fits my use-case and I see no issues 👍🏼

I do have a question about internal systems though. I've been using public libraries in the same way that @dk1a described, so I don't see a need for the internal systems. Is there something that public libraries cannot do that the internal systems can?

@dk1a
Copy link
Contributor

dk1a commented Jan 9, 2023

If I am understanding everything correctly, this proposal fits my use-case and I see no issues 👍🏼
#327 comment

Looks good to me too

Is there something that public libraries cannot do that the internal systems can?

I have considered public libs as a workable alternative, mostly they can do what internal systems can.
Exceptions:

  • Callbacks, but I use those only for 1 internal system.
  • ERC721/1155 Subsystems can't be libs, but they're special and I wouldn't need a concept of "internal system" just for them.

I have a bias against public libs because I don't like how they fit into the deployment pipeline.
I am curious to see how you do it in skystrife, maybe it's not all as bad as I imagine.
Also these points from my writeup on benefits of subsystems over internal libs apply to public libs as well:

  1. Callbacks (see feat(std-contracts): add SystemCallbackComponent #303) are impossible with libs.
  2. I don't even know how many components CycleCombatSystem uses if you unroll the subsystems. Manually managing huge writeAccesses is very inconvenient.
  3. Libraries don't have writeAccess. Subsystems do. You can easily see which components/subsystems a subsystem writes to. You have to read the whole library to know what it writes to.
  4. Statefulness can be convenient. I don't need to pass world or components to a subsystem.

#319 comment

(the last 2 points are very minor and wouldn't have made a difference on their own)

@Kooshaba
Copy link
Contributor

As far as deployment I've been using the default MUD deploy using forge and everything just works out of the box. I believe the only small issue is that it deploys a new library every time it is referenced in a system.

I agree that managing write access is pretty nice with subsystems. I currently avoid the entire problem and just set writeAcess: * in all my systems 😅 Not ideal.

@alvrs
Copy link
Member Author

alvrs commented Jan 10, 2023

Player Journey:

Player Journey:

  • player registers for a Sky Strife match
  • during registration:
    • Approval is created for the Amalgema JoinMatchSystem to impersonate them (1 call)
    • player calls JoinMatchSystem, which calls MacroJoinMatchSystem and joins match
  • during join:
    • Approval is created for the Sky Strife ItemExtractionSystem to impersonate them (1 call)
    • player plays match and accumulates items
    • on match end, player calls ItemExtractionSystem, which calls MatchItemExtractionSystem, and transfers items to Amalgema

- #327 (comment)

If I understand the flow correctly, I think MacroJoinMatchSystem (grantor) could just create an approval for JoinMatchSystem, essentially turning MacroJoinMatchSystem into a "Library" for JoinMatchSystem. With the spec from the initial issue, this would mean JoinMatchSystem calls MacroJoinMatchSystem via executeInternal, so it can pass any from address and MacroJoinMatchSystem trusts it (which is fine because they're both coming from the same developer and JoinMatchSystem's code can be verified to just forward its msg.sender (or it's from to be preceise)).

The relationship between ItemExtractorSystem with MatchItemExtractionSystem would be along the same lines, with MatchItemExtractionSystem creating an approval for ItemExtractorSystem to allow it to be called like a subsystem.

In other words, the flow you described sounds like a use case for executeInternal / "Subsystem" more than for impersonating players.

Note: the flow you described would also work, but would require each player to approve the JoinMatchSystem to call the MacroJoinMatchSystem on their behalf, and then would require JoinMatchSystem to call MacroJoinMatchSystem via its executeFrom method.

However, there would also be a use case for the executeFrom / "impersonation" / "session wallets" in Sky Strife:

  • Amalgema is aware of user's "main wallet", items are associated with the main wallet, crafting etc. in the Amalgema client requires players to approve a transaction
  • When a Sky Strife match is joined:
    • The flow described above happens
    • The Sky Strife client creates a burner wallet for the match
    • The user creates an Approval for this burner wallet in the Sky Strife world once.
  • The Sky Strife client calls all systems via their executeFrom method via the burner wallet, passing the user's main wallet as from parameter. ("The burner wallet impersonates the main wallet"). Sky Strife's internal logic (ie the systems' _execute methods) doesn't have to be aware of this, they just trust their from parameter, and the default executeFrom method (or modifier) handles the access validation.

@Osub
Copy link
Contributor

Osub commented Jan 11, 2023

How about MPC wallet ?

@alvrs
Copy link
Member Author

alvrs commented Jan 11, 2023

How about MPC wallet ?
- #327 (comment)

@Osub can you elaborate?

@Osub
Copy link
Contributor

Osub commented Jan 12, 2023

MPC wallets are multi-party computing shared key fragments that can interact with contracts using 2 of 3 signatures. part1 exists client, part2 game operation, part3 user backup. Interact with the contract using part1 and part2 co-signatures. The user registers the game operation server with a social account or mobile phone number, refer to https://docs.particle.network/

@dk1a
Copy link
Contributor

dk1a commented Jan 13, 2023

The relationship between ItemExtractorSystem with MatchItemExtractionSystem would be along the same lines, with MatchItemExtractionSystem creating an approval for ItemExtractorSystem to allow it to be called like a subsystem.
#327 (comment)

I had thought it was intentional, allowing not-fully-trusted third-party sky strife clients for amalgema or something like that. But otherwise executeInternal would fit well indeed.

@holic
Copy link
Member

holic commented Jan 13, 2023

For account delegation/session wallets/burner wallets, I wonder if we should use something more general purpose/widely supported, that way more things outside of MUD can plug into it directly, e.g. https://delegate.cash/

@dk1a
Copy link
Contributor

dk1a commented Jan 14, 2023

Started an implementation in #345, it should help discussing the finer details.
I outlined some points that came to mind there.
Check out its general architecture first though, if you want drastic changes, that may make all my smaller questions invalid

@alvrs alvrs changed the title General approval pattern (for modular systems and session wallets) Proposal: General approval pattern (for modular systems and session wallets) Jan 16, 2023
@alvrs
Copy link
Member Author

alvrs commented Jan 17, 2023

For account delegation/session wallets/burner wallets, I wonder if we should use something more general purpose/widely supported, that way more things outside of MUD can plug into it directly, e.g. https://delegate.cash/
- #327 (comment) by @holic

Valid point! If we go with a central entry point (as discussed in #347) we could also easily provide multiple entry points for different account abstraction methods, without requiring system developers to think about it / to upgrade (they can just trust that the _from / _msgSender param passed by the World is authenticated with some valid method)

@alvrs
Copy link
Member Author

alvrs commented Jan 17, 2023

Started an implementation in #345, it should help discussing the finer details.
I outlined some points that came to mind there.
Check out its general architecture first though, if you want drastic changes, that may make all my smaller questions invalid
- #327 (comment) by @dk1a

Wow very cool! Lots of good points in the PR description.

However I wonder if we should delay this issue until we settled on sth in #347 (since it impacts a lot of code that would be written for this one) and adjust this proposal for #347 when the time has come? #347 will obviously be a breaking v2 change, but I wonder if it's worth putting effort in a v1 approval system (and requiring developers to upgrade all their systems if they want to use it) instead of waiting for v2 (which still requires devs to upgrade, but at least only once, and then things like the approval pattern can be implemented in a non-breaking way in the central World entry point).

@dk1a
Copy link
Contributor

dk1a commented Jan 17, 2023

However I wonder if we should delay this issue until we settled on sth in #347
#327 (comment)

I had the same thoughts after reading the proposal.
Personally I much prefer the more thorough changes and simply didn't expect the v2 data model to be coming soon. I'll close #345, as I'd rather contribute to v2. If someone wants to continue #345 feel free to copy my changes

@alvrs
Copy link
Member Author

alvrs commented Jul 10, 2023

Closing this in favor of #1124

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion A discussion (without clear action/outcome)
Projects
None yet
Development

No branches or pull requests

6 participants