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: account abstraction and paymaster syntax #1

Closed
wants to merge 14 commits into from

Conversation

idea404
Copy link

@idea404 idea404 commented Dec 15, 2023

What 💻

This PR introduces both an AbstractWallet class and a Paymaster class to simplify sending initialising AA accounts and sending paymaster-endorsed transactions. Developers will also be able to customise their own signature methods for the account abstraction by importing the signingFunction and redefining it.

Why ✋

Sending transactions currently involves either (i) creating custom classes to override signature functions or (ii) constructing raw transaction objects from scratch and manually signing and serialising.

Evidence 📷

const accountAbstractionAddress = "...";
const provider = Provider.getDefaultProvider(types.Network.Localhost);
const ethProvider = ethers.getDefaultProvider("http://localhost:8545");
const wallet = new Wallet(PRIVATE_KEY, provider, ethProvider);
paymaster = new Paymaster("ApprovalBased", paymasterAddress, tokenAddress);

const abstractWallet = new AbstractWallet(
    accountAbstractionAddress,
    wallet.privateKey,
    provider,
    ethProvider,
    paymaster,
);

const tx = await abstractWallet.transfer({
    to: wallet.address, 
    amount: ethers.parseEther("10")
});
await tx.wait();

Testing 🧪

  • Clone the repo
  • Install dependencies with yarn
  • Run local-setup
  • Run yarn test:aa

TODOs ✔️

  • remove console.log
  • implement integration tests with contracts
  • implement signingFunction override integration tests
  • integration test both General and AB Paymaster usage with AbstractWallet
  • lint files and add linter to CI
  • add testing CI

export function encodeData(data: any[]): string {
const types = data.map(item => {
if (typeof item === 'number') {
return 'uint256';
Copy link
Contributor

Choose a reason for hiding this comment

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

what if I need uint128 or int64?

Copy link
Author

Choose a reason for hiding this comment

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

good question. I thought the same while setting up this function too (another reason why I left this PR as draft). I would think now then that it is not possible to deduce these lower levels types without the developer specifying them either through an ABI or some form of input. This changes the syntax for constructing both the Paymaster and abstractWallet classes, considering they both can potentially have methods that serialise data.

@@ -124,3 +125,52 @@ export class Wallet extends AdapterL2(AdapterL1(ethers.Wallet)) {
return await this.provider.broadcastTransaction(await this.signTransaction(populatedTx));
}
}

interface SigningFunction {
(this: AbstractWallet, transaction: TransactionRequest): Promise<string>;
Copy link
Contributor

@Romsters Romsters Dec 15, 2023

Choose a reason for hiding this comment

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

does it need access tothis (AbstractWallet)?

Copy link
Author

@idea404 idea404 Dec 18, 2023

Choose a reason for hiding this comment

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

It could otherwise accept the wallet as a parameter as:

    (abstractWallet: AbstractWallet, transaction: TransactionRequest): Promise<string>;

Both approaches are fine by me

paymasterParams: this.paymaster.getPaymasterParams(),
} : {};
transaction.customData = this._fillCustomData(transaction.customData);
transaction.customData.customSignature = await this.eip712.sign(transaction);
Copy link
Contributor

Choose a reason for hiding this comment

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

how does it work? What key is used to sign it?

Copy link
Author

Choose a reason for hiding this comment

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

This will use the privateKey parameter passed to the constructor here. Ultimately, a SigningKey function is added at ethers.BaseWallet, the class that ethers.Wallet extends (and this class is extended by zks.Wallet), using this passed in private key that is used for signatures.

I find it also tricky to know what exactly is going on with the class as it extends another class about 5 times.. But this approach adds the least lines of code too.

providerL2?: Provider,
providerL1?: ethers.Provider,
paymaster: Paymaster | null = null,
customSigningFunction: SigningFunction = signingFunction,
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need a default here? Or should we just make it required?

Copy link
Author

Choose a reason for hiding this comment

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

I find it makes the syntax for declaration simpler by adding a default here. I prefer it because of that. I can imagine some developers might not be interested in overriding the signing function and only seek to use AA for the custom logic you can add to accounts on-chain. In these cases I can imagine they'd be happy not having to be concerned with the signing logic, or paymaster logic either (if they do not wish to use a paymaster).

) {
super(privateKey, providerL2);
this.accountAddress = accountAddress;
this.paymaster = paymaster;
Copy link
Contributor

Choose a reason for hiding this comment

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

why is this.paymaster here? How I can I control it's usage if it's here?

Copy link
Author

Choose a reason for hiding this comment

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

this.paymaster is called in the signingFunction logic inside of signTransaction which is called for signing any transaction. The goal here was to provide a way for the developer to specify using a paymaster by default on any transaction. Currently the syntax in signingFunction is:

   ...
    transaction.customData = this.paymaster ? {
        gasPerPubdata: DEFAULT_GAS_PER_PUBDATA_LIMIT,
        paymasterParams: this.paymaster.getPaymasterParams(),
    } : {};
    ...

But I think it would be better to change it actually to:

  ...
   transaction.customData ??= this.paymaster ? {
       gasPerPubdata: DEFAULT_GAS_PER_PUBDATA_LIMIT,
       paymasterParams: this.paymaster.getPaymasterParams(),
   } : {};
   ...

Considering the developer might want to specify some transactions on the frontend where a paymaster is applied, and some others not. But if the developer wants a paymaster to be used in all transactions, then they can simply declare the paymaster in this constructor here.

expect(difference / BigInt(10 ** 18) < 10.1).to.be.true;
}).timeout(25_000);

it.skip("should transfer DAI", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

skip

expect(balanceAfterTransfer - balanceBeforeTransfer).to.be.equal(BigInt(amount));
}).timeout(25_000);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we please add a test for just calling some smart contract?

};
}

getPaymasterParams() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need this class if we simply want to build paymaster params? Shouldn't it be a util instead?

Copy link
Author

Choose a reason for hiding this comment

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

Actually I also notice this method will require adding parameters as those are required for the ApprovalBased paymasters that accept a quantity of tokens to cover the transaction cost.

As for making this a class, this class will also include a method to enrich a transaction with the data for paymasterParams which is useful when developers only want to sponsor some transactions. Something like .addParamsToTransaction(trx) that calls getPaymasterParams to fill in the transaction object. This removes the need for the developer to manipulate the transaction object manually.

Not precious about this either though, so if you think strongly of keeping these as functions then we could go that way but I find this:

let tx = contract.methodWrite(...);
tx = utils.addPaymasterParamsToTransaction(utils.getPaymasterParams(...));

Less appealing than:

const paymaster = new Paymaster(...);
let tx = contract.methodWrite(...);
tx = paymaster.addParamsToTransaction(tx);

@idea404 idea404 closed this Dec 30, 2023
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.

2 participants