-
Notifications
You must be signed in to change notification settings - Fork 106
Smart Backend Wallets #709
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
Conversation
|
|
||
| const json = walletDetails.encryptedJson; | ||
|
|
||
| const wallet = await Wallet.fromEncryptedJson( |
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 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), |
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 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.
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 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
| export const createAndStoreAwsKmsWallet = async ({ | ||
| label, | ||
| ...overrides | ||
| }: CreateAwsKmsWalletParams): Promise<string> => { | ||
| const { awsKmsArn, params } = await createAwsKmsWallet(overrides); | ||
|
|
||
| return importAwsKmsWallet({ |
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 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
👇
createAwsKmsKeycreateAwsKmsWalletorcreateAwsKmsWalletDetailsorcreateAwsKmsWalletToDb- This function can remain
createAwsKmsWalletsince 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>, |
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.
Keep naming consistent with the above AWS KMS flow (change either of them).
- above: input is named
params, here it'spartialParams - above: the fetched params is named
awsKmsParams, here it'sparams
| import { getAwsKmsAccount } from "./getAwsKmsAccount"; | ||
| import { getGcpKmsAccount } from "./getGcpKmsAccount"; | ||
|
|
||
| const createSmartWallet = async ({ |
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.
Same comment here on naming. Is this "creating a smart wallet" or simply "getting" one? Is there a clearer verb here?
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.
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 | ||
| */ |
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'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()?
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 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({ |
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 seems way too low level to maintain within Engine. Does this exist in thirdweb or underlying SDKs?
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 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.
src/utils/account.ts
Outdated
| case WalletType.smartGcpKms: | ||
| case WalletType.smartLocal: { | ||
| if (!walletDetails.accountSignerAddress) { | ||
| throw createCustomError( |
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.
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.
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 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",
);
}
src/utils/account.ts
Outdated
| 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, | ||
| }); | ||
| } |
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.
Doesn't the createSmartWallet() helper function do all this already?
| privateKey: string; | ||
| address: string; | ||
| }> { | ||
| console.log(encryptedJson); |
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.
remove logging
- add sbw flow for v4 sdk getWallet
- Refactor walletDetailsToAccount function and add caching for admin accounts
| awsKmsSecretAccessKey: string; // will be encrypted and stored, pass plaintext to this function | ||
| awsKmsAccessKeyId: string; |
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.
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.
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 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.
| // we will never reach here | ||
| // this helps typescript understand that this function will always return |
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.
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 |
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.
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:
signerAddressadminAddress
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 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.
| 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; | ||
| } |
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 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.
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.
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 |
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.
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"); |
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.
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.
src/utils/cache/getWallet.ts
Outdated
|
|
||
| const splitArn = splitAwsKmsArn(walletDetails.awsKmsArn); | ||
|
|
||
| const personalWallet = new AwsKmsWallet({ |
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.
Same nit: this is the adminWallet or signerWallet (pick one), so keep naming consistent.
| const account = await getAccount({ | ||
| chainId: transaction.chainId, | ||
| from: transaction.from, | ||
| }); | ||
|
|
||
| resultTransaction = await _sendTransaction(job, transaction, 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.
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.
| // 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); | ||
| } |
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.
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( |
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.
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; |
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.
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 () => { |
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.
Nit: "random" sounds unclear to me (especially since the other analogous KMS ones don't have it). createLocalWallet was fine, or generateLocalWallet?
src/utils/account.ts
Outdated
| sponsorGas: true, | ||
| factoryAddress: walletDetails.accountFactoryAddress ?? undefined, | ||
| const connectedWallet = await getConnectedSmartWallet({ | ||
| adminAccount: signerAccount, |
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.
Nit: Saw you used signerAccount here (AWS) but adminAccount below (GCP)
|
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. |
src/lib/chain/chain-capabilities.ts
Outdated
| enabled: boolean; | ||
| }>; | ||
|
|
||
| // Create cache with 2048 entries and 5 minute TTL |
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.
Comment is wrong
src/lib/cache/swr.ts
Outdated
| this.dedupedFetch(key, fetchFn).catch(() => { | ||
| // Silence background revalidation errors | ||
| }); |
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.
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.
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
entrypointAddressandaccountFactoryAddressto wallet schemas.smart:aws-kms,smart:gcp-kms, andsmart:local.