-
Notifications
You must be signed in to change notification settings - Fork 196
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
Conversation
🦋 Changeset detectedLatest commit: 42a802b The changes in this PR will be included in the next version bump. This PR includes changesets to release 24 packages
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 |
packages/world/package.json
Outdated
@@ -11,6 +11,7 @@ | |||
"type": "module", | |||
"exports": { | |||
".": "./dist/ts/index.js", | |||
"./actions": "./dist/ts/actions/index.js", |
There was a problem hiding this comment.
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.
@holic Could you review this when you have a moment? |
There was a problem hiding this 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> => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
writeContract: (originalArgs): Promise<WriteContractReturnType> => { | |
writeContract: (writeArgs): Promise<WriteContractReturnType> => { |
writeArgs
or writeContractArgs
might be a little more clear when we're juggling with callFromArgs
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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>({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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?
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
.changeset/nervous-nails-notice.md
Outdated
delegation({ | ||
worldAddress, | ||
delegatorAddress, | ||
getSystemId: (functionSelector) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getSystemId: (functionSelector) => | |
getSystemId: async (functionSelector) => |
similar as the other comment
|
||
// `callFrom` requires `systemId`. | ||
const functionSelector = toFunctionSelector(formatAbiItem(functionAbiItem)); | ||
const systemId = getSystemId(functionSelector); |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
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; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 🤔
There was a problem hiding this comment.
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"> { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense!
There was a problem hiding this 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, |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
@holic I've added the default function that reads data from the world contract. Could you take a look? 🥷 |
Regarding the dev-tools issue, thanks to #2392, we can now use |
There was a problem hiding this 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.
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, | ||
); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah, getSchemaTypes
packages/world/package.json
Outdated
@@ -11,6 +11,7 @@ | |||
"type": "module", | |||
"exports": { | |||
".": "./dist/index.js", | |||
"./actions": "./dist/actions.js", |
There was a problem hiding this comment.
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")
// 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)]); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
There was a problem hiding this 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
.changeset/nervous-nails-notice.md
Outdated
@@ -0,0 +1,23 @@ | |||
--- | |||
"@latticexyz/world": minor |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense!
@alvrs Thanks for your comments! I've made the function internal and updated the changeset. |
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 withcallFrom
.It can be used like this: