Skip to content

Conversation

@d4mr
Copy link
Contributor

@d4mr d4mr commented Oct 7, 2024

PR-Codex overview

This PR introduces enhancements to wallet management, including support for smart wallets, new wallet types, and updated database schemas. It also improves caching mechanisms and adds tests for new features.

Detailed summary

  • Added entrypointAddress and accountFactoryAddress to wallet schemas.
  • Introduced smart wallet types: smart:aws-kms, smart:gcp-kms, and smart:local.
  • Updated database migrations for new fields.
  • Enhanced wallet creation functions to support smart wallets.
  • Implemented caching for chain capabilities.
  • Added tests for smart backend wallets.
  • Deprecated some existing wallet functions and marked them for removal.

The following files were skipped due to too many changes: src/utils/cache/getWallet.ts, src/db/wallets/getWalletDetails.ts, src/utils/account.ts

✨ Ask PR-Codex anything about this PR by commenting with /codex {your question}

@d4mr d4mr requested a review from arcoraven October 7, 2024 00:35

const json = walletDetails.encryptedJson;

const wallet = await Wallet.fromEncryptedJson(
Copy link
Contributor Author

@d4mr d4mr Oct 7, 2024

Choose a reason for hiding this comment

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

this can be moved over to using decryptJsonWallet from legacyLocalCrypto. The interface isn't as clunky here though, so we can let it be too.

accountFactoryAddress?: Address;
}) => {
const smartAccount = smartWallet({
chain: defineChain(1),
Copy link
Member

Choose a reason for hiding this comment

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

I assume you're only using this to predict the smart wallet address?

The danger here is that it requires the account factory to be there on that chain.

I think for smart accounts it's better to recreate the smart wallet at runtime based on the chain coming in the request.

So you would create a normal backend wallet and just give it a smart flag in the db (which you kinda do already with the factory address) then on every request, compute the smart wallet based on the chain in the request. If factory not available on that chain = error.

Copy link
Contributor Author

@d4mr d4mr Oct 7, 2024

Choose a reason for hiding this comment

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

I think for smart accounts it's better to recreate the smart wallet at runtime based on the chain coming in the request.

That's the intention here, did I miss something in the implementation?

then on every request, compute the smart wallet based on the chain in the request

This would be confusing. We must ensure smart wallet has same account address on every chain for UX. I assumed this was already always the case, so we are storing the predicted account address.

If factory not available on that chain = error.

Exactly. Right now all the inner interfaces support overriding accountFactoryAddress, but I have not exposed it to the endpoint. We throw an error if:

  • user provided accountFactory does not have exist
  • OR user did not provide accountFactoryAddress and thirdweb default factory does not exist

Comment on lines 21 to 27
export const createAndStoreAwsKmsWallet = async ({
label,
...overrides
}: CreateAwsKmsWalletParams): Promise<string> => {
const { awsKmsArn, params } = await createAwsKmsWallet(overrides);

return importAwsKmsWallet({
Copy link
Contributor

Choose a reason for hiding this comment

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

I think our verbs are getting confusing. Let's use fewer unique verbs but add details (even if longer var/fn names) for clarity.

  • create
  • import
  • store

👇

  • createAwsKmsKey
  • createAwsKmsWallet or createAwsKmsWalletDetails or createAwsKmsWalletToDb
  • This function can remain createAwsKmsWallet since that makes sense

* If any required parameter cannot be resolved from either the configuration or the overrides, an error is thrown.
*/
export const createGcpKmsWallet = async (
partialParams: Partial<GcpKmsWalletParams>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Keep naming consistent with the above AWS KMS flow (change either of them).

  • above: input is named params, here it's partialParams
  • above: the fetched params is named awsKmsParams, here it's params

import { getAwsKmsAccount } from "./getAwsKmsAccount";
import { getGcpKmsAccount } from "./getGcpKmsAccount";

const createSmartWallet = async ({
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment here on naming. Is this "creating a smart wallet" or simply "getting" one? Is there a clearer verb here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed this is confusing, I like your generate suggestion. It's still a little unclear because for AWS and GCP, generate would still have side effect (interactions with AWS and GCP).

/**
* @deprecated
* DEPRECATED: Use getLocalWalletAccount instead
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

There's only one place this is called:

// src/utils/cache/getWallet.ts:116
wallet = await getLocalWallet({ chainId, walletAddress });

Could we resolve this wallet from the response from getLocalWalletAccount()?

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 can't because there's no way to expose the privateKey from the Account that we get from getLocalWallet. lmk if you think it's worth exposing it separately.

version: number;
}

export async function toEncryptedJson({
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems way too low level to maintain within Engine. Does this exist in thirdweb or underlying SDKs?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this replaces the LocalStorage class implementation that we needed to do, which had dependencies on v4 SDK and ethers. We could alternatively use the ethers code directly, but I wanted to remove all ethers dependencies.

case WalletType.smartGcpKms:
case WalletType.smartLocal: {
if (!walletDetails.accountSignerAddress) {
throw createCustomError(
Copy link
Contributor

Choose a reason for hiding this comment

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

Just throw new Error here if you expect a 5xx response anyway. We should minimize status-code specific logic in helper or reused functions since it's a bad pattern.

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 saw line 38 so assumed it was okay to throw HTTP error here. Completely agree though, it's a bad pattern

  if (!walletDetails) {
    throw createCustomError(
      `No configured wallet found with address ${from}`,
      StatusCodes.BAD_REQUEST,
      "BAD_REQUEST",
    );
  }

Comment on lines 161 to 176
const adminAccount = await getAccount({
chainId,
from: getAddress(walletDetails.accountSignerAddress),
});

const unconnectedSmartWallet = smartWallet({
chain: await getChain(chainId),
sponsorGas: true,
factoryAddress: walletDetails.accountFactoryAddress ?? undefined,
});

return await unconnectedSmartWallet.connect({
client: thirdwebClient,
personalAccount: adminAccount,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Doesn't the createSmartWallet() helper function do all this already?

privateKey: string;
address: string;
}> {
console.log(encryptedJson);
Copy link
Contributor

Choose a reason for hiding this comment

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

remove logging

@socket-security
Copy link

socket-security bot commented Oct 17, 2024

New and removed dependencies detected. Learn more about Socket for GitHub ↗︎

Package New capabilities Transitives Size Publisher
npm/@types/[email protected] None +1 386 kB types
npm/[email protected] eval, filesystem Transitive: environment, network, shell, unsafe +71 295 MB egoist

🚮 Removed packages: npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@esbuild/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@rollup/[email protected], npm/@types/[email protected], npm/@types/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected], npm/[email protected]

View full report↗︎

Comment on lines +21 to +22
awsKmsSecretAccessKey: string; // will be encrypted and stored, pass plaintext to this function
awsKmsAccessKeyId: string;
Copy link
Contributor

Choose a reason for hiding this comment

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

Won't making these fields non-optional be a breaking change? We could ofc update the dashboard, but it'll impact anyone in the slim chance they're automating KMS backend wallet creation.

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 decided to always denormalize and store credentials in the DB row, regardless of it being an override or coming from config. Users don't have to pass it in, the route fills in this from the config.

Comment on lines 163 to 164
// we will never reach here
// this helps typescript understand that this function will always return
Copy link
Contributor

Choose a reason for hiding this comment

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

No need for these comments IMO. Should be clear. And if it's not, just make the thrown error message more clear.

gcpApplicationCredentialEmail String? @map("gcpApplicationCredentialEmail") /// if not available, default to: Configuration.gcpApplicationCredentialEmail
gcpApplicationCredentialPrivateKey String? @map("gcpApplicationCredentialPrivateKey") /// if not available, default to: Configuration.gcpApplicationCredentialPrivateKey
// Smart Backend Wallet
accountSignerAddress String? @map("accountSignerAddress") /// this, and either local, aws or gcp encryptedJson, are required for smart wallet
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: This naming confused me because account made me think this is the same as smart account address, esp when next to accountFactoryAddress. I think these names are clearer:

  • signerAddress
  • adminAddress

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 WalletDetails schema is very denormalized, so in the interest of maximum clarity, I think having an account prefix is better here.

Between signer and admin, I think signer is better. It leaves us with more flexibility later to allow importing SBW if we want.

Comment on lines +195 to +211
try {
return walletDetailsSchema.parse(walletDetails, {
errorMap: (issue) => {
const fieldName = issue.path.join(".");
return {
message: `${fieldName} is necessary for wallet ${address} of type ${walletDetails.type}, but not found in wallet details or configuration`,
};
},
});
} catch (e) {
if (e instanceof z.ZodError) {
throw new WalletDetailsError(
e.errors.map((error) => error.message).join(", "),
);
}
throw e;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This zod parsing + prettifying error thing is nice, but definitely belongs in a util where we can re-use it. Not-blocking, but something to eventually abstract so we have a nicer interface here like

return prettyZodParse(walletDetails, walletDetailsSchema)

Then it's also very unit testable.

Copy link
Contributor Author

@d4mr d4mr Oct 21, 2024

Choose a reason for hiding this comment

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

Agreed. Also unrelated, but I think we should move from a util mindset to a lib mindset. util has us thinking about one-off things that we can store to reuse later. lib would require us to be more intentional about categorizing and co-locating relevant functionality, so it's easier to discover/reuse later. We can start incrementally in this direction with a parse lib and error lib


return {
account,
// these exact values are stored for backwards compatibility
Copy link
Contributor

Choose a reason for hiding this comment

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

If only data is read and the others aren't read anymore, shouldn't it be safe to only store data now?

});
} catch (e) {
if (e instanceof WalletDetailsError) {
throw createCustomError(e.message, 400, "BAD_REQUEST");
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Use StatusCodes everywhere for consistency (it'll be helpful if we're searching for where a certain status code is emitted). Or if you don't like em we should just switch off them entirely and use raw numbers.


const splitArn = splitAwsKmsArn(walletDetails.awsKmsArn);

const personalWallet = new AwsKmsWallet({
Copy link
Contributor

Choose a reason for hiding this comment

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

Same nit: this is the adminWallet or signerWallet (pick one), so keep naming consistent.

Comment on lines 138 to 143
const account = await getAccount({
chainId: transaction.chainId,
from: transaction.from,
});

resultTransaction = await _sendTransaction(job, transaction, account);
Copy link
Contributor

Choose a reason for hiding this comment

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

It didn't seem like we needed to change this: getAccount can still be called inside _sendTransaction. If there's not a good reason to, let's revert this change.

Comment on lines 102 to 136
// this can either be a regular backend wallet userop or a smart backend wallet userop
const accountAddress = transaction.accountAddress;
assert(accountAddress, "Invalid userOp parameters: accountAddress");

let adminAccount: Account | undefined;

try {
adminAccount = await getSmartBackendWalletAdminAccount({
accountAddress,
chainId: transaction.chainId,
});
} catch {
// do nothing, this might still be a regular backend wallet userop
}

try {
adminAccount = await getAccount({
chainId: transaction.chainId,
from: transaction.from,
});
} catch {
job.log(
`Failed to get admin account for userOp: ${stringify(transaction)}`,
);
}

if (!adminAccount) {
resultTransaction = {
...transaction,
status: "errored",
errorMessage: "Failed to get admin account",
};
} else {
resultTransaction = await _sendUserOp(job, transaction, adminAccount);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can all this just be moved inside _sendUserOp? Not sure if it needs to be out here, and it keeps all userOp logic inside the _sendUserOp function.

from: transaction.from,
});
} catch {
job.log(
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't need this added logging. When this job errors, the .on("failed") listener at the bottom of this file already job.log's all errors with the error message.

requestBody: {
message: string;
isBytes?: boolean;
chainId?: number;
Copy link
Contributor

Choose a reason for hiding this comment

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

Note from our convo: can you check if signing on different chains result in the same output. If so, we could default to a testnet (Sepolia?).

* Does not store the wallet in the database
*/
export const createLocalWallet = async () => {
export const createRandomLocalWallet = async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: "random" sounds unclear to me (especially since the other analogous KMS ones don't have it). createLocalWallet was fine, or generateLocalWallet?

sponsorGas: true,
factoryAddress: walletDetails.accountFactoryAddress ?? undefined,
const connectedWallet = await getConnectedSmartWallet({
adminAccount: signerAccount,
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Saw you used signerAccount here (AWS) but adminAccount below (GCP)

@github-actions
Copy link

This PR is stale because it has been open for 7 days with no activity. Remove stale label or comment or this PR will be closed in 3 days.

enabled: boolean;
}>;

// Create cache with 2048 entries and 5 minute TTL
Copy link
Contributor

Choose a reason for hiding this comment

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

Comment is wrong

Comment on lines 37 to 39
this.dedupedFetch(key, fetchFn).catch(() => {
// Silence background revalidation errors
});
Copy link
Contributor

Choose a reason for hiding this comment

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

So this catch makes it if any error occurs while fetching, the old data continues to be used.

Let's at least log a warning here. If this errors silently we won't know if the cache is stuck in a stale state.

@d4mr d4mr merged commit 94afe24 into main Nov 4, 2024
5 checks passed
@d4mr d4mr deleted the pb/sbw branch November 4, 2024 02:28
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.

4 participants