diff --git a/.changeset/good-carpets-dream.md b/.changeset/good-carpets-dream.md new file mode 100644 index 00000000000..6516f8e5051 --- /dev/null +++ b/.changeset/good-carpets-dream.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Handle updating session keys with new params and expose `shouldUpdateSessionKey` from `extensions/erc4337` diff --git a/packages/thirdweb/src/exports/extensions/erc4337.ts b/packages/thirdweb/src/exports/extensions/erc4337.ts index d8be1f43720..8c27e4e1c3c 100644 --- a/packages/thirdweb/src/exports/extensions/erc4337.ts +++ b/packages/thirdweb/src/exports/extensions/erc4337.ts @@ -16,6 +16,7 @@ export { type AddSessionKeyOptions, addSessionKey, isAddSessionKeySupported, + shouldUpdateSessionKey, } from "../../extensions/erc4337/account/addSessionKey.js"; export { diff --git a/packages/thirdweb/src/extensions/erc4337/account/addSessionKey.ts b/packages/thirdweb/src/extensions/erc4337/account/addSessionKey.ts index d3a3c0f4a17..ba320619e77 100644 --- a/packages/thirdweb/src/extensions/erc4337/account/addSessionKey.ts +++ b/packages/thirdweb/src/extensions/erc4337/account/addSessionKey.ts @@ -1,5 +1,10 @@ +import { ZERO_ADDRESS } from "../../../constants/addresses.js"; +import type { ThirdwebContract } from "../../../contract/contract.js"; import type { BaseTransactionOptions } from "../../../transaction/types.js"; +import { isContractDeployed } from "../../../utils/bytecode/is-contract-deployed.js"; +import { toWei } from "../../../utils/units.js"; import type { Account } from "../../../wallets/interfaces/wallet.js"; +import { getPermissionsForSigner } from "../__generated__/IAccountPermissions/read/getPermissionsForSigner.js"; import { isSetPermissionsForSignerSupported, setPermissionsForSigner, @@ -86,3 +91,78 @@ export function addSessionKey( export function isAddSessionKeySupported(availableSelectors: string[]) { return isSetPermissionsForSignerSupported(availableSelectors); } + +/** + * Checks if the session key should be updated. + * @param currentPermissions - The current permissions of the session key. + * @param newPermissions - The new permissions to set for the session key. + * @returns A boolean indicating if the session key should be updated. + */ +export async function shouldUpdateSessionKey(args: { + accountContract: ThirdwebContract; + sessionKeyAddress: string; + newPermissions: AccountPermissions; +}): Promise { + const { accountContract, sessionKeyAddress, newPermissions } = args; + + // check if account is deployed + const accountDeployed = await isContractDeployed(accountContract); + if (!accountDeployed) { + return true; + } + + // get current permissions + const currentPermissions = await getPermissionsForSigner({ + contract: accountContract, + signer: sessionKeyAddress, + }); + // check end time validity + if ( + currentPermissions.endTimestamp && + currentPermissions.endTimestamp < Math.floor(new Date().getTime() / 1000) + ) { + return true; + } + + // check targets + if ( + !areSessionKeyContractTargetsEqual( + currentPermissions.approvedTargets, + newPermissions.approvedTargets, + ) + ) { + return true; + } + + // check if the new native token limit is greater than the current one + if ( + toWei(newPermissions.nativeTokenLimitPerTransaction?.toString() ?? "0") > + currentPermissions.nativeTokenLimitPerTransaction + ) { + return true; + } + + return false; +} + +function areSessionKeyContractTargetsEqual( + currentTargets: readonly string[], + newTargets: string[] | "*", +): boolean { + // Handle the case where approvedTargets is "*" + if ( + newTargets === "*" && + currentTargets.length === 1 && + currentTargets[0] === ZERO_ADDRESS + ) { + return true; + } + if (newTargets !== "*") { + return newTargets + .map((target) => target.toLowerCase()) + .every((target) => + currentTargets.map((t) => t.toLowerCase()).includes(target), + ); + } + return false; +} diff --git a/packages/thirdweb/src/extensions/erc4337/account/permissions.test.ts b/packages/thirdweb/src/extensions/erc4337/account/permissions.test.ts index 6083d111ca6..b8eb359803a 100644 --- a/packages/thirdweb/src/extensions/erc4337/account/permissions.test.ts +++ b/packages/thirdweb/src/extensions/erc4337/account/permissions.test.ts @@ -1,9 +1,11 @@ import { beforeAll, describe, expect, it } from "vitest"; import { ANVIL_CHAIN } from "../../../../test/src/chains.js"; import { TEST_CLIENT } from "../../../../test/src/test-clients.js"; +import { USDT_CONTRACT_ADDRESS } from "../../../../test/src/test-contracts.js"; import { TEST_ACCOUNT_A, TEST_ACCOUNT_B, + TEST_ACCOUNT_C, } from "../../../../test/src/test-wallets.js"; import { ZERO_ADDRESS } from "../../../constants/addresses.js"; import { @@ -20,7 +22,7 @@ import { adminUpdatedEvent } from "../__generated__/IAccountPermissions/events/A import { signerPermissionsUpdatedEvent } from "../__generated__/IAccountPermissions/events/SignerPermissionsUpdated.js"; import { getAllAdmins } from "../__generated__/IAccountPermissions/read/getAllAdmins.js"; import { addAdmin } from "./addAdmin.js"; -import { addSessionKey } from "./addSessionKey.js"; +import { addSessionKey, shouldUpdateSessionKey } from "./addSessionKey.js"; import { removeAdmin } from "./removeAdmin.js"; describe.runIf(process.env.TW_SECRET_KEY)("Account Permissions", () => { @@ -129,5 +131,62 @@ describe.runIf(process.env.TW_SECRET_KEY)("Account Permissions", () => { expect(logs[0]?.args.permissions.approvedTargets).toStrictEqual([ ZERO_ADDRESS, ]); + + expect( + await shouldUpdateSessionKey({ + accountContract, + sessionKeyAddress: TEST_ACCOUNT_A.address, + newPermissions: { + approvedTargets: "*", + }, + }), + ).toBe(false); + + expect( + await shouldUpdateSessionKey({ + accountContract, + sessionKeyAddress: TEST_ACCOUNT_A.address, + newPermissions: { + approvedTargets: "*", + nativeTokenLimitPerTransaction: 0, + }, + }), + ).toBe(false); + + expect( + await shouldUpdateSessionKey({ + accountContract, + sessionKeyAddress: TEST_ACCOUNT_A.address, + newPermissions: { + approvedTargets: [USDT_CONTRACT_ADDRESS], + }, + }), + ).toBe(true); + + expect( + await shouldUpdateSessionKey({ + accountContract, + sessionKeyAddress: TEST_ACCOUNT_A.address, + newPermissions: { + approvedTargets: "*", + nativeTokenLimitPerTransaction: 0.1, + }, + }), + ).toBe(true); + }); + + it("should update session key if account is not deployed", async () => { + const shouldUpdate = await shouldUpdateSessionKey({ + accountContract: getContract({ + address: TEST_ACCOUNT_C.address, + chain: ANVIL_CHAIN, + client: TEST_CLIENT, + }), + sessionKeyAddress: TEST_ACCOUNT_A.address, + newPermissions: { + approvedTargets: "*", + }, + }); + expect(shouldUpdate).toBe(true); }); }); diff --git a/packages/thirdweb/src/wallets/smart/index.ts b/packages/thirdweb/src/wallets/smart/index.ts index a9da9d2c478..cb9570ef572 100644 --- a/packages/thirdweb/src/wallets/smart/index.ts +++ b/packages/thirdweb/src/wallets/smart/index.ts @@ -6,8 +6,10 @@ import type { ThirdwebClient } from "../../client/client.js"; import { type ThirdwebContract, getContract } from "../../contract/contract.js"; import { allowance } from "../../extensions/erc20/__generated__/IERC20/read/allowance.js"; import { approve } from "../../extensions/erc20/write/approve.js"; -import { isActiveSigner } from "../../extensions/erc4337/__generated__/IAccountPermissions/read/isActiveSigner.js"; -import { addSessionKey } from "../../extensions/erc4337/account/addSessionKey.js"; +import { + addSessionKey, + shouldUpdateSessionKey, +} from "../../extensions/erc4337/account/addSessionKey.js"; import { sendTransaction } from "../../transaction/actions/send-transaction.js"; import { toSerializableTransaction } from "../../transaction/actions/to-serializable-transaction.js"; import type { WaitForReceiptOptions } from "../../transaction/actions/wait-for-tx-receipt.js"; @@ -19,7 +21,6 @@ import type { PreparedTransaction } from "../../transaction/prepare-transaction. import { readContract } from "../../transaction/read-contract.js"; import { getAddress } from "../../utils/address.js"; import { isZkSyncChain } from "../../utils/any-evm/zksync/isZkSyncChain.js"; -import { isContractDeployed } from "../../utils/bytecode/is-contract-deployed.js"; import type { Hex } from "../../utils/encoding/hex.js"; import { resolvePromisedValue } from "../../utils/promise/resolve-promised-value.js"; import { parseTypedData } from "../../utils/signatures/helpers/parse-typed-data.js"; @@ -169,16 +170,13 @@ export async function connectSmartAccount( smartAccountToAdminAccountMap.set(account, personalAccount); if (options.sessionKey) { - let hasSessionKey = false; - // check if already added - const accountDeployed = await isContractDeployed(accountContract); - if (accountDeployed) { - hasSessionKey = await isActiveSigner({ - contract: accountContract, - signer: options.sessionKey.address, - }); - } - if (!hasSessionKey) { + if ( + await shouldUpdateSessionKey({ + accountContract, + sessionKeyAddress: options.sessionKey.address, + newPermissions: options.sessionKey.permissions, + }) + ) { const transaction = addSessionKey({ account: personalAccount, contract: accountContract,