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

feat(world): add viem actions for delegation #2366

Merged
merged 18 commits into from
Mar 25, 2024

Conversation

tash-2s
Copy link
Contributor

@tash-2s tash-2s commented Mar 4, 2024

This code has been pulled out from #2314.

By extending viem clients with this actions function after delegation, the delegation is automatically applied to World contract writes. Internally, it transforms the writeContract arguments into one that wraps it with callFrom.

It can be used like this:

walletClient.extend(
  delegation({
    worldAddress,
    delegatorAddress,
    getSystemId: (functionSelector) =>
      network.useStore.getState().getValue(network.tables.FunctionSelectors, { functionSelector })!.systemId,
  }),
);

@tash-2s tash-2s requested review from alvrs, holic and yonadaa as code owners March 4, 2024 19:37
Copy link

changeset-bot bot commented Mar 4, 2024

🦋 Changeset detected

Latest commit: 42a802b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 24 packages
Name Type
@latticexyz/world Patch
@latticexyz/cli Patch
@latticexyz/dev-tools Patch
@latticexyz/store-sync Patch
@latticexyz/world-modules Patch
@latticexyz/store-indexer Patch
@latticexyz/abi-ts Patch
@latticexyz/block-logs-stream Patch
@latticexyz/common Patch
@latticexyz/config Patch
create-mud Patch
@latticexyz/faucet Patch
@latticexyz/gas-report Patch
@latticexyz/protocol-parser Patch
@latticexyz/query Patch
@latticexyz/react Patch
@latticexyz/recs Patch
@latticexyz/schema-type Patch
@latticexyz/services Patch
solhint-config-mud Patch
solhint-plugin-mud Patch
@latticexyz/store Patch
@latticexyz/utils Patch
mock-game-contracts Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@@ -11,6 +11,7 @@
"type": "module",
"exports": {
".": "./dist/ts/index.js",
"./actions": "./dist/ts/actions/index.js",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Following the common library's actions path, I've made a new path for this. But we only have this function now, so I can move the function into ./ts/delegationActions.ts if preferred.

@tash-2s
Copy link
Contributor Author

tash-2s commented Mar 4, 2024

@holic Could you review this when you have a moment?

@tash-2s tash-2s mentioned this pull request Mar 4, 2024
Copy link
Member

@holic holic left a comment

Choose a reason for hiding this comment

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

This is a great first pass!

A few notes around naming, sync vs async interface, and how we're translating between world function selectors and system function selectors.

) => Pick<WalletActions<TChain, TAccount>, "writeContract"> {
return (client) => ({
// Applies to: `client.writeContract`, `getContract(client, ...).write`
writeContract: (originalArgs): Promise<WriteContractReturnType> => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
writeContract: (originalArgs): Promise<WriteContractReturnType> => {
writeContract: (writeArgs): Promise<WriteContractReturnType> => {

writeArgs or writeContractArgs might be a little more clear when we're juggling with callFromArgs

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, writeArgs has more info in the name, thus it is better.

type DelegationParameters = {
worldAddress: Hex;
delegatorAddress: Hex;
getSystemId: (functionSelector: Hex) => Hex;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
getSystemId: (functionSelector: Hex) => Hex;
getSystemId: (functionSelector: Hex) => Promise<Hex>;

We should probably make this async because in the future 1) our client query APIs will be async and 2) we may wanna automatically retrieve this from the world


// By extending clients with this function after delegation, the delegation automatically applies
// to the World contract writes, meaning these calls are made on behalf of the delegator.
export function delegation<TChain extends Chain, TAccount extends Account>({
Copy link
Member

@holic holic Mar 5, 2024

Choose a reason for hiding this comment

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

Suggested change
export function delegation<TChain extends Chain, TAccount extends Account>({
export function callFrom<TChain extends Chain, TAccount extends Account>({

Since "delegation" is a bigger concept, how about calling this callFrom or delegatedCalls or similar?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The change makes sense!

systemId,
functionName: originalArgs.functionName,
args: originalArgs.args,
} as unknown as SystemCallFrom),
Copy link
Member

Choose a reason for hiding this comment

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

ooc why do we need to cast this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When wrapping this kind of viem functions that take an abi, we often need this kind of type cast. This is because it requires the actual abi types to provide strong typing. (in this case, to type functionName and args)

E.g., https://github.com/wevm/viem/blob/viem%402.7.19/src/actions/wallet/writeContract.ts#L156-L162

delegation({
worldAddress,
delegatorAddress,
getSystemId: (functionSelector) =>
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
getSystemId: (functionSelector) =>
getSystemId: async (functionSelector) =>

similar as the other comment


// `callFrom` requires `systemId`.
const functionSelector = toFunctionSelector(formatAbiItem(functionAbiItem));
const systemId = getSystemId(functionSelector);
Copy link
Member

@holic holic Mar 5, 2024

Choose a reason for hiding this comment

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

I think we might be missing some nuance around world function selectors vs system function selectors here.

When we call a namespaced system, the originalArgs.functionName will be something like namespace__functionName and the ABI will likely be the IWorld ABI (all of the world functions).

But when we translate that to a system function call, we need to pass in the system ID + system function selector (i.e. just functionName without the namespace, since the namespace is part of the system ID), not the world function selector. If you saw this working before, it could be because the root namespace would mask this nuance (i.e. no namespace prefix for world function selectors).

Maybe getSystemId needs to be something like worldFunctionToSystemFunction or getSystemCall or similar? It takes in a world function selector and returns both the system ID and system function selector. The args will be the same, but we may have to manually encode the args instead of using encodeSystemCallFrom.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was misunderstanding this point. As you mentioned, it only worked for root namespace systems.

Copy link
Contributor Author

@tash-2s tash-2s left a comment

Choose a reason for hiding this comment

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

Thank you so much for your comments! 😸 I've addressed all issues.

The callFrom function has been tested with calls for root/non-root systems, with and without call args.

Comment on lines 65 to 83
const functionData = decodeFunctionData({ abi: worldAbi, data: functionSelectorAndArgs });
functionName = functionData.functionName;
functionArgs = functionData.args;

// TODO: Since `functionSelectorAndArgs` corresponds to a System's function, decoding it using
// the World ABI may not always be successful. For instance, namespaced system calls could
// result in an error.
try {
const functionData = decodeFunctionData({ abi: worldAbi, data: functionSelectorAndArgs });
functionName = functionData.functionName;
functionArgs = functionData.args;
} catch (error) {
if (!(error instanceof AbiFunctionSignatureNotFoundError)) {
throw error;
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've found an error in dev-tools. I've made a workaround, but I'm not sure what's the best way to address this, so leaving this as TODO now.

For root namespaced calls:
Screenshot 2024-03-05 at 21 39 55

For non-root namespaced calls (This was an error before this change):
Screenshot 2024-03-05 at 21 35 54

Copy link
Member

@holic holic Mar 6, 2024

Choose a reason for hiding this comment

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

we could try to do the inverse, where we resolve the system function selector form the world one and assume the same args? but I guess we don't have the system signature in that case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we don't have the system signature

right 🤔

Copy link
Member

Choose a reason for hiding this comment

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

added an issue to track this here: #2382

worldFunctionToSystemFunction,
}: CallFromParameters): (
client: WalletClient<Transport, TChain, TAccount>,
) => Pick<WalletActions<TChain, TAccount>, "writeContract"> {
Copy link
Member

Choose a reason for hiding this comment

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

since we already have an assumption here about this being a world with callFrom, we could add an async check that the client account has a proper delegation set up for delegator address and warn if not

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could implement a delegation check here, but would it be useful?

This function should throw an error if the delegation is not set up, and the current implementation throws the error already. Adding a warning message isn't necessary since it would fail with a clear revert message. Also, we shouldn't allow the call to proceed without delegation, as its usage is intended with delegation in place.

Copy link
Member

Choose a reason for hiding this comment

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

makes sense!

Copy link
Member

@holic holic left a comment

Choose a reason for hiding this comment

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

had another thought about making this API easier to use by creating a default method for retrieving the system function selectors

curious what you think of the approach?

export function callFrom<TChain extends Chain, TAccount extends Account>({
worldAddress,
delegatorAddress,
worldFunctionToSystemFunction,
Copy link
Member

@holic holic Mar 6, 2024

Choose a reason for hiding this comment

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

thought about this some more and since we already assume you're operating on a world and function selectors never change once set (i.e. we can safely cache them here), I think we can get rid of this option in favor of something like:

type SystemFunction = { readonly systemId: Hex, readonly systemFunctionSelector: Hex };

const worldFunctionSelectorCache = new Map<Hex, SystemFunction>();

async function worldFunctionToSystemFunction(client: PublicClient, worldAddress: Address, worldFunctionSelector: Hex): Promise<SystemFunction> {
  const cached = worldFunctionSelectorCache.get(worldFunctionSelector);
  if (cached != null) return cached;

  const systemFunction = await readContract(client, {
    address: worldAddress,
    abi: worldAbi,
    functionName: 'getRecord',
    args: [worldConfig.tables.FunctionSelectors.tableId, worldFunctionSelector]
  });
  worldFunctionSelectorCache.set(worldFunctionSelector, systemFunction);
  return systemFunction;
}

(Note that this is pseudocode and may require parsing the record from the getRecord call, and I'm not sure how best to expect the public client, wether we expect to extend a public client or take it as an argument)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, it's better to have that automatic retrieving, so users without this info sync can use this function more easily.

Getting a public client as an argument is more flexible, as it doesn't ask users to extend their wallet clients with public actions, so they have options. I'll implement this.

@tash-2s
Copy link
Contributor Author

tash-2s commented Mar 7, 2024

@holic I've added the default function that reads data from the world contract. Could you take a look? 🥷

@tash-2s
Copy link
Contributor Author

tash-2s commented Mar 8, 2024

Regarding the dev-tools issue, thanks to #2392, we can now use FunctionSignatures to display human-readable call / callFrom actions. Since it's an off-chain table, we can use zustand's useStore if provided.

tash-2s added a commit that referenced this pull request Mar 9, 2024
Copy link
Contributor Author

@tash-2s tash-2s left a comment

Choose a reason for hiding this comment

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

I've merged main and aligned with the changes in main.

Comment on lines 119 to 125
const table = worldConfig.tables.world__FunctionSelectors;

const _keySchema = getKeySchema(table);
const keySchema = mapObject<typeof _keySchema, { [K in keyof typeof _keySchema]: (typeof _keySchema)[K]["type"] }>(
_keySchema,
({ type }) => type,
);
Copy link
Contributor Author

@tash-2s tash-2s Mar 23, 2024

Choose a reason for hiding this comment

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

This keySchema's type is { worldFunctionSelector: "bytes4" }. Without specifying the generics type argument (using mapObject(_keySchema, ...)), the type results in { worldFunctionSelector: unknown }.

Regarding getting schemas for encoding/decoding, is there a better way to handle it with the new config?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah, getSchemaTypes

@@ -11,6 +11,7 @@
"type": "module",
"exports": {
".": "./dist/index.js",
"./actions": "./dist/actions.js",
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 need to make this a separate export now that we don't have global side effects on the global export. We can just export this action via /ts/exports/index.ts (or ts/exports/internal.ts for now until we want to make it "public")

Comment on lines 76 to 82
// Construct the System's calldata.
// If there's no args, use the System's function selector as calldata.
// Otherwise, use the World's calldata, replacing the World's function selector with the System's.
const systemCalldata =
worldCalldata === worldFunctionSelector
? systemFunctionSelector
: concat([systemFunctionSelector, slice(worldCalldata, 4)]);
Copy link
Member

Choose a reason for hiding this comment

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

why not always concat([systemFunctionSelector, slice(worldCalldata, 4)]);? if worldCalldata === worldFunctionSelector then that means slice(worldCalldata, 4) is empty, so concat would be a noop, so we don't need to handle it with a special case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using const systemCalldata = concat([systemFunctionSelector, slice(worldCalldata, 4)]); could result in an out-of-bounds error in slice() if there's no arg.
(SliceOffsetOutOfBoundsError: Slice starting at offset "4" is out-of-bounds (size: 4).)

I could alternatively use size(worldCalldata) === 4 as a condition. Would that be better?

Copy link
Member

Choose a reason for hiding this comment

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

could we use readHex from @latticexyz/common here? That should handle the case where the start would be out of bounds

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 having a helper function that splits the function calldata into selector and argument data would simplify this function a bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@alvrs I'll look into readHex. thanks!

Copy link
Member

@alvrs alvrs left a comment

Choose a reason for hiding this comment

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

This is great! just some minor nits, but aside from those i think this is ready to be merged

@@ -0,0 +1,23 @@
---
"@latticexyz/world": minor
Copy link
Member

Choose a reason for hiding this comment

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

I would opt for not exporting this from world/external yet to test it a bit and bundle the external feature release with a couple other libraries for the wallet connection flow. That means i would declare this as patch for now and add a note to the changeset that it's not ready for "stable" consumption yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

makes sense!

@tash-2s
Copy link
Contributor Author

tash-2s commented Mar 25, 2024

@alvrs Thanks for your comments! I've made the function internal and updated the changeset.

@alvrs alvrs merged commit a09bf25 into main Mar 25, 2024
11 checks passed
@alvrs alvrs deleted the tash-2s/viem-world-delegation-actions branch March 25, 2024 19:41
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 this pull request may close these issues.

3 participants