diff --git a/.changeset/chatty-chairs-mate.md b/.changeset/chatty-chairs-mate.md new file mode 100644 index 00000000000..d73e47b67c9 --- /dev/null +++ b/.changeset/chatty-chairs-mate.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Introduces Session Keys to EIP-7702-powered In-App Wallets via a new createSessionKey extension diff --git a/packages/thirdweb/src/exports/wallets/in-app.native.ts b/packages/thirdweb/src/exports/wallets/in-app.native.ts index 845ca991809..86c31a64949 100644 --- a/packages/thirdweb/src/exports/wallets/in-app.native.ts +++ b/packages/thirdweb/src/exports/wallets/in-app.native.ts @@ -1,5 +1,15 @@ // --- KEEEP IN SYNC with exports/wallets/in-app.ts --- +//ACCOUNT +export { + type CreateSessionKeyOptions, + createSessionKey, + isCreateSessionKeySupported, +} from "../../extensions/erc7702/account/createSessionKey.js"; +export type { + Condition, + LimitType, +} from "../../extensions/erc7702/account/types.js"; export type { GetAuthenticatedUserParams, MultiStepAuthArgsType, diff --git a/packages/thirdweb/src/exports/wallets/in-app.ts b/packages/thirdweb/src/exports/wallets/in-app.ts index 2524501ad8f..2ac3774b555 100644 --- a/packages/thirdweb/src/exports/wallets/in-app.ts +++ b/packages/thirdweb/src/exports/wallets/in-app.ts @@ -1,5 +1,15 @@ // --- KEEEP IN SYNC with exports/wallets/in-app.native.ts --- +//ACCOUNT +export { + type CreateSessionKeyOptions, + createSessionKey, + isCreateSessionKeySupported, +} from "../../extensions/erc7702/account/createSessionKey.js"; +export type { + Condition, + LimitType, +} from "../../extensions/erc7702/account/types.js"; export { getSocialIcon, socialIcons, diff --git a/packages/thirdweb/src/extensions/erc7702/account/createSessionKey.ts b/packages/thirdweb/src/extensions/erc7702/account/createSessionKey.ts new file mode 100644 index 00000000000..81c92ea1032 --- /dev/null +++ b/packages/thirdweb/src/extensions/erc7702/account/createSessionKey.ts @@ -0,0 +1,181 @@ +import type { BaseTransactionOptions } from "../../../transaction/types.js"; +import { randomBytesHex } from "../../../utils/random.js"; +import type { Account } from "../../../wallets/interfaces/wallet.js"; +import { + createSessionWithSig, + isCreateSessionWithSigSupported, +} from "../__generated__/MinimalAccount/write/createSessionWithSig.js"; +import { + type CallSpecInput, + CallSpecRequest, + ConstraintRequest, + SessionSpecRequest, + type TransferSpecInput, + TransferSpecRequest, + UsageLimitRequest, +} from "./types.js"; + +/** + * @extension ERC7702 + */ +export type CreateSessionKeyOptions = { + /** + * The admin account that will perform the operation. + */ + account: Account; + /** + * The address to add as a session key. + */ + sessionKeyAddress: string; + /** + * How long the session key should be valid for, in seconds. + */ + durationInSeconds: number; + /** + * Whether to grant full execution permissions to the session key. + */ + grantFullPermissions?: boolean; + /** + * Smart contract interaction policies to apply to the session key, ignored if grantFullPermissions is true. + */ + callPolicies?: CallSpecInput[]; + /** + * Value transfer policies to apply to the session key, ignored if grantFullPermissions is true. + */ + transferPolicies?: TransferSpecInput[]; +}; + +/** + * Creates session key permissions for a specified address. + * @param options - The options for the createSessionKey function. + * @param {Contract} options.contract - The EIP-7702 smart EOA contract to create the session key from + * @returns The transaction object to be sent. + * @example + * ```ts + * import { createSessionKey } from 'thirdweb/extensions/7702'; + * import { sendTransaction } from 'thirdweb'; + * + * const transaction = createSessionKey({ + * account: account, + * contract: accountContract, + * sessionKeyAddress: TEST_ACCOUNT_A.address, + * durationInSeconds: 86400, // 1 day + * grantFullPermissions: true + *}) + * + * await sendTransaction({ transaction, account }); + * ``` + * @extension ERC7702 + */ +export function createSessionKey( + options: BaseTransactionOptions, +) { + const { + contract, + account, + sessionKeyAddress, + durationInSeconds, + grantFullPermissions, + callPolicies, + transferPolicies, + } = options; + + if (durationInSeconds <= 0) { + throw new Error("durationInSeconds must be positive"); + } + + return createSessionWithSig({ + async asyncParams() { + const req = { + callPolicies: (callPolicies || []).map((policy) => ({ + constraints: (policy.constraints || []).map((constraint) => ({ + condition: Number(constraint.condition), + index: constraint.index || BigInt(0), + limit: constraint.limit + ? { + limit: constraint.limit.limit, + limitType: Number(constraint.limit.limitType), + period: constraint.limit.period, + } + : { + limit: BigInt(0), + limitType: 0, + period: BigInt(0), + }, + refValue: constraint.refValue || "0x", + })), + maxValuePerUse: policy.maxValuePerUse || BigInt(0), + selector: policy.selector, + target: policy.target, + valueLimit: policy.valueLimit + ? { + limit: policy.valueLimit.limit, + limitType: Number(policy.valueLimit.limitType), + period: policy.valueLimit.period, + } + : { + limit: BigInt(0), + limitType: 0, + period: BigInt(0), + }, + })), + expiresAt: BigInt(Math.floor(Date.now() / 1000) + durationInSeconds), + isWildcard: grantFullPermissions ?? true, + signer: sessionKeyAddress, + transferPolicies: (transferPolicies || []).map((policy) => ({ + maxValuePerUse: policy.maxValuePerUse || BigInt(0), + target: policy.target, + valueLimit: policy.valueLimit + ? { + limit: policy.valueLimit.limit, + limitType: Number(policy.valueLimit.limitType), + period: policy.valueLimit.period, + } + : { + limit: BigInt(0), + limitType: 0, + period: BigInt(0), + }, + })), + uid: await randomBytesHex(), + }; + + const signature = await account.signTypedData({ + domain: { + chainId: contract.chain.id, + name: "MinimalAccount", + verifyingContract: contract.address, + version: "1", + }, + message: req, + primaryType: "SessionSpec", + types: { + CallSpec: CallSpecRequest, + Constraint: ConstraintRequest, + SessionSpec: SessionSpecRequest, + TransferSpec: TransferSpecRequest, + UsageLimit: UsageLimitRequest, + }, + }); + + return { sessionSpec: req, signature }; + }, + contract, + }); +} + +/** + * Checks if the `isCreateSessionKeySupported` method is supported by the given contract. + * @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors. + * @returns A boolean indicating if the `isAddSessionKeySupported` method is supported. + * @extension ERC7702 + * @example + * ```ts + * import { isCreateSessionKeySupported } from "thirdweb/extensions/erc7702"; + * + * const supported = isCreateSessionKeySupported(["0x..."]); + * ``` + */ +export function isCreateSessionKeySupported(availableSelectors: string[]) { + return isCreateSessionWithSigSupported(availableSelectors); +} diff --git a/packages/thirdweb/src/extensions/erc7702/account/sessionkey.test.ts b/packages/thirdweb/src/extensions/erc7702/account/sessionkey.test.ts new file mode 100644 index 00000000000..156fcc8ee6c --- /dev/null +++ b/packages/thirdweb/src/extensions/erc7702/account/sessionkey.test.ts @@ -0,0 +1,132 @@ +import { defineChain } from "src/chains/utils.js"; +import { prepareTransaction } from "src/transaction/prepare-transaction.js"; +import { inAppWallet } from "src/wallets/in-app/web/in-app.js"; +import type { Account } from "src/wallets/interfaces/wallet.js"; +import { beforeAll, describe, expect, it } from "vitest"; +import { TEST_CLIENT } from "../../../../test/src/test-clients.js"; +import { TEST_ACCOUNT_A } from "../../../../test/src/test-wallets.js"; +import { ZERO_ADDRESS } from "../../../constants/addresses.js"; +import { + getContract, + type ThirdwebContract, +} from "../../../contract/contract.js"; +import { parseEventLogs } from "../../../event/actions/parse-logs.js"; +import { sendAndConfirmTransaction } from "../../../transaction/actions/send-and-confirm-transaction.js"; +import { sessionCreatedEvent } from "../__generated__/MinimalAccount/events/SessionCreated.js"; +import { createSessionKey } from "./createSessionKey.js"; +import { Condition, LimitType } from "./types.js"; + +describe.runIf(process.env.TW_SECRET_KEY)( + "Session Key Behavior", + { + retry: 0, + timeout: 240_000, + }, + () => { + const chainId = 11155111; + let account: Account; + let accountContract: ThirdwebContract; + + beforeAll(async () => { + // Create 7702 Smart EOA + const wallet = inAppWallet({ + executionMode: { + mode: "EIP7702", + sponsorGas: true, + }, + }); + account = await wallet.connect({ + chain: defineChain(chainId), + client: TEST_CLIENT, + strategy: "guest", + }); + + // Send a null tx to trigger deploy/upgrade + await sendAndConfirmTransaction({ + account: account, + transaction: prepareTransaction({ + chain: defineChain(chainId), + client: TEST_CLIENT, + to: account.address, + value: 0n, + }), + }); + + // Will auto resolve abi since it's deployed + accountContract = getContract({ + address: account.address, + chain: defineChain(chainId), + client: TEST_CLIENT, + }); + }, 120_000); + + it("should allow adding adminlike session keys", async () => { + const receipt = await sendAndConfirmTransaction({ + account: account, + transaction: createSessionKey({ + account: account, + contract: accountContract, + durationInSeconds: 86400, + grantFullPermissions: true, // 1 day + sessionKeyAddress: TEST_ACCOUNT_A.address, + }), + }); + const logs = parseEventLogs({ + events: [sessionCreatedEvent()], + logs: receipt.logs, + }); + expect(logs[0]?.args.signer).toBe(TEST_ACCOUNT_A.address); + }); + + it("should allow adding granular session keys", async () => { + const receipt = await sendAndConfirmTransaction({ + account: account, + transaction: createSessionKey({ + account: account, + callPolicies: [ + { + constraints: [ + { + condition: Condition.Unconstrained, + index: 0n, + refValue: + "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + ], + maxValuePerUse: 0n, + selector: "0x00000000", + target: ZERO_ADDRESS, + valueLimit: { + limit: 0n, + limitType: LimitType.Unlimited, + period: 0n, + }, + }, + ], + contract: accountContract, + durationInSeconds: 86400, // 1 day + grantFullPermissions: false, + sessionKeyAddress: TEST_ACCOUNT_A.address, + transferPolicies: [ + { + maxValuePerUse: 0n, + target: ZERO_ADDRESS, + valueLimit: { + limit: 0n, + limitType: 0, + period: 0n, + }, + }, + ], + }), + }); + const logs = parseEventLogs({ + events: [sessionCreatedEvent()], + logs: receipt.logs, + }); + expect(logs[0]?.args.signer).toBe(TEST_ACCOUNT_A.address); + expect(logs[0]?.args.sessionSpec.callPolicies).toHaveLength(1); + expect(logs[0]?.args.sessionSpec.transferPolicies).toHaveLength(1); + }); + }, +); diff --git a/packages/thirdweb/src/extensions/erc7702/account/types.ts b/packages/thirdweb/src/extensions/erc7702/account/types.ts new file mode 100644 index 00000000000..3e337d9d160 --- /dev/null +++ b/packages/thirdweb/src/extensions/erc7702/account/types.ts @@ -0,0 +1,94 @@ +/* ──────────────────────────────── + Enums + ──────────────────────────────── */ + +export enum LimitType { + Unlimited = 0, + Lifetime = 1, + Allowance = 2, +} + +export enum Condition { + Unconstrained = 0, + Equal = 1, + Greater = 2, + Less = 3, + GreaterOrEqual = 4, + LessOrEqual = 5, + NotEqual = 6, +} + +/* ──────────────────────────────── + Input types + ──────────────────────────────── */ + +/* ---------- UsageLimit ---------- */ +interface UsageLimitInput { + limitType: LimitType; + limit: bigint; + period: bigint; +} + +/* ---------- Constraint ---------- */ +interface ConstraintInput { + condition: Condition; + index: bigint; + refValue: `0x${string}`; + limit?: UsageLimitInput; +} + +/* ---------- CallSpec ---------- */ +export interface CallSpecInput { + target: `0x${string}`; + selector: `0x${string}`; + maxValuePerUse?: bigint; + valueLimit?: UsageLimitInput; + constraints?: ConstraintInput[]; +} + +/* ---------- TransferSpec ---------- */ +export interface TransferSpecInput { + target: `0x${string}`; + maxValuePerUse?: bigint; + valueLimit?: UsageLimitInput; +} + +/* ──────────────────────────────── + EIP-712 structs + ──────────────────────────────── */ + +export const UsageLimitRequest = [ + { name: "limitType", type: "uint8" }, + { name: "limit", type: "uint256" }, + { name: "period", type: "uint256" }, +] as const; + +export const ConstraintRequest = [ + { name: "condition", type: "uint8" }, + { name: "index", type: "uint64" }, + { name: "refValue", type: "bytes32" }, + { name: "limit", type: "UsageLimit" }, +] as const; + +export const CallSpecRequest = [ + { name: "target", type: "address" }, + { name: "selector", type: "bytes4" }, + { name: "maxValuePerUse", type: "uint256" }, + { name: "valueLimit", type: "UsageLimit" }, + { name: "constraints", type: "Constraint[]" }, +] as const; + +export const TransferSpecRequest = [ + { name: "target", type: "address" }, + { name: "maxValuePerUse", type: "uint256" }, + { name: "valueLimit", type: "UsageLimit" }, +] as const; + +export const SessionSpecRequest = [ + { name: "signer", type: "address" }, + { name: "isWildcard", type: "bool" }, + { name: "expiresAt", type: "uint256" }, + { name: "callPolicies", type: "CallSpec[]" }, + { name: "transferPolicies", type: "TransferSpec[]" }, + { name: "uid", type: "bytes32" }, +] as const; diff --git a/packages/thirdweb/src/wallets/in-app/core/eip7702/minimal-account.ts b/packages/thirdweb/src/wallets/in-app/core/eip7702/minimal-account.ts index 9fbf05a1cf4..b23be9f5ca4 100644 --- a/packages/thirdweb/src/wallets/in-app/core/eip7702/minimal-account.ts +++ b/packages/thirdweb/src/wallets/in-app/core/eip7702/minimal-account.ts @@ -24,7 +24,7 @@ import { import type { BundlerOptions } from "../../../smart/types.js"; const MINIMAL_ACCOUNT_IMPLEMENTATION_ADDRESS = - "0xbaC7e770af15d130Cd72838ff386f14FBF3e9a3D"; + "0xD6999651Fc0964B9c6B444307a0ab20534a66560"; export const create7702MinimalAccount = (args: { client: ThirdwebClient;