import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator"; import { PasskeyValidatorContractVersion, toPasskeyValidator, } from "@zerodev/passkey-validator"; import { type KernelAccountClient, KernelV3ExecuteAbi, createKernelAccountClient, } from "@zerodev/sdk"; import { createZeroDevPaymasterClient } from "@zerodev/sdk"; import { createKernelAccount } from "@zerodev/sdk"; import { KERNEL_V3_1, getEntryPoint } from "@zerodev/sdk/constants"; import type { WebAuthnKey } from "@zerodev/webauthn-key"; import { http, type Address, type Call, type Chain, type Hash, type Hex, type TransactionRequest, type Transport, createPublicClient, decodeFunctionData, encodeFunctionData, erc20Abi, isAddressEqual, toHex, zeroAddress, } from "viem"; import type { SmartAccount } from "viem/account-abstraction"; import { toAccount } from "viem/accounts"; import { arbitrumSepolia } from "viem/chains"; import { SIMULATION_URL, WALLET_URL, ZERODEV_URL } from "./constants.js"; import type { ProviderEventCallback, RequestArguments, } from "./types/provider.js"; import type { Signer } from "./types/signer.js"; import type { Token, TokenChanged } from "./types/simulation.js"; type YiSignerConstructorParameters = { callback: ProviderEventCallback | null; projectId: string; }; export class YiSigner implements Signer { private accounts: Address[] = []; private chain = arbitrumSepolia; private subAccountClient: KernelAccountClient< Transport, Chain, SmartAccount > | null = null; private masterAccountAddress: Address | null = null; private projectId: string; private callback: ProviderEventCallback | null; constructor(params: YiSignerConstructorParameters) { this.callback = params.callback; this.projectId = params.projectId; } async request(request: RequestArguments) { switch (request.method) { case "eth_requestAccounts": this.callback?.("connect", { chainId: toHex(this.chain.id) }); return this.accounts; case "eth_accounts": return this.accounts; case "eth_coinbase": return this.accounts[0]; case "eth_chainId": return toHex(this.chain.id); case 'eth_sendTransaction': case "wallet_sendCalls": return this.requestToYiWallet(request); // return this.sendCalls(request); case "wallet_getCallsStatus": return this.getCallsStatus(request); default: return Promise.reject(new Error("Method not implemented")); } } async sendCalls(request: RequestArguments) { if (!Array.isArray(request.params) || request.params.length < 1) { return Promise.reject(new Error("No params for sendCalls")); } const { calls } = request.params[0]; const subAccount = this.subAccountClient?.account; if (!subAccount) { return Promise.reject(new Error("No account address")); } try { const userOpHash = await this.subAccountClient?.sendUserOperation({ calls, }); return { id: userOpHash, }; } catch (err) { const pullTokenCalls = await this.getPullTokenCalls(calls); // no pull token calls, throw the error if (pullTokenCalls.length === 0) { throw err; } const userOpHash = await this.subAccountClient?.sendUserOperation({ calls: pullTokenCalls, }); return { id: userOpHash, }; } } async getCallsStatus(request: RequestArguments) { if (!Array.isArray(request.params) || request.params.length < 1) { return Promise.reject(new Error("No params for getCallsStatus")); } const userOpReceipts = await Promise.all( request.params.map(async (id) => { const userOpReceipt = await this.subAccountClient?.waitForUserOperationReceipt({ hash: id, }); return userOpReceipt; }), ); const allSuccess = userOpReceipts.every((r) => r?.success); return { version: "2.0.0", id: request.params[0], atomic: true, chainId: this.chain.id, status: allSuccess ? "200" : "400", receipts: userOpReceipts.map((r) => r?.receipt), }; } async handshake(_args: RequestArguments, _projectId: string) { // Create an iframe for authentication const iframe = document.createElement("iframe"); iframe.src = `${WALLET_URL}/auth?client_id=example_client_1`; iframe.style.width = "600px"; iframe.style.height = "600px"; iframe.style.border = "none"; iframe.style.position = "fixed"; iframe.style.top = "50%"; iframe.style.left = "50%"; iframe.style.transform = "translate(-50%, -50%)"; iframe.style.zIndex = "9999"; iframe.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.15)"; iframe.style.borderRadius = "12px"; iframe.allow = "publickey-credentials-get; publickey-credentials-create"; // Create overlay background const overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.top = "0"; overlay.style.left = "0"; overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; overlay.style.zIndex = "9998"; // Add to the DOM document.body.appendChild(overlay); document.body.appendChild(iframe); // Helper function to safely remove elements const safelyRemoveElements = () => { if (iframe.parentNode) { iframe.parentNode.removeChild(iframe); } if (overlay.parentNode) { overlay.parentNode.removeChild(overlay); } }; // Return a promise that resolves when we receive the response return new Promise((resolve, reject) => { const handleMessage = async (event: MessageEvent) => { // Verify the origin for security if (event.origin !== WALLET_URL) return; try { // Process the response data const { token, userId, signerAddress, index, masterAccountAddress, subAccountAddress, webAuthnKey, error, } = event.data; // Clean up the iframe and overlay safelyRemoveElements(); if (error) { reject(new Error(error)); return; } if (masterAccountAddress) { this.masterAccountAddress = masterAccountAddress; } // Set the account if provided if (subAccountAddress && webAuthnKey && index) { const parsedWebAuthnKey = JSON.parse(webAuthnKey); const bnWebauthnKey = { ...parsedWebAuthnKey, pubX: BigInt(parsedWebAuthnKey.pubX), pubY: BigInt(parsedWebAuthnKey.pubY), }; const subAccountClient = await this.createSubAccount( // signerAddress, bnWebauthnKey, BigInt(index), ); this.subAccountClient = subAccountClient; this.accounts = [subAccountAddress]; } // Resolve with the data resolve({ token, userId }); } catch (error) { reject(error); } finally { window.removeEventListener("message", handleMessage); } }; // Set up listener for the response window.addEventListener("message", handleMessage); // Add close functionality to the overlay overlay.addEventListener("click", () => { safelyRemoveElements(); window.removeEventListener("message", handleMessage); reject(new Error("Authentication cancelled by user")); }); }); } async signMessage( _message: string | Uint8Array | { raw: Hex | Uint8Array }, ): Promise { const response = await fetch(`${WALLET_URL}/api/sign`, { method: "POST", body: JSON.stringify({ message: _message, address: this.subAccountClient?.account.address, }), }); if (!response.ok) { const error = await response.json(); throw new Error("Failed to sign message", { cause: error }); } const { signature } = await response.json(); return signature; } async signTransaction(_transaction: TransactionRequest): Promise { console.log("signTransaction", _transaction); return "0x"; } // biome-ignore lint/suspicious/noExplicitAny: typedData async signTypedData(_typedData: any): Promise { return "0x"; } async createSubAccount(webAuthnKey: WebAuthnKey, index: bigint) { const entryPoint = getEntryPoint("0.7"); const kernelVersion = KERNEL_V3_1; const zerodevRpc = `${ZERODEV_URL}/${this.projectId}/chain/${this.chain.id}`; const publicClient = createPublicClient({ chain: this.chain, transport: http(), }); const passkeyValidator = await toPasskeyValidator(publicClient, { webAuthnKey, entryPoint: getEntryPoint("0.7"), kernelVersion: KERNEL_V3_1, validatorContractVersion: PasskeyValidatorContractVersion.V0_0_2, }); // Construct a Kernel account const account = await createKernelAccount(publicClient, { plugins: { sudo: passkeyValidator, }, entryPoint, kernelVersion, index, }); const zerodevPaymaster = createZeroDevPaymasterClient({ chain: this.chain, transport: http(zerodevRpc), }); // Construct a Kernel account client const kernelClient = createKernelAccountClient({ account, chain: this.chain, bundlerTransport: http(zerodevRpc), // Required - the public client client: publicClient, paymaster: { getPaymasterData(userOperation) { return zerodevPaymaster.sponsorUserOperation({ userOperation }); }, }, }); return kernelClient; } async cleanup() { this.accounts = []; this.chain = arbitrumSepolia; } async getPullTokenCalls(calls: Call[]): Promise { const subAccount = this.subAccountClient?.account; if (!subAccount) { return Promise.reject(new Error("No account address")); } const encodedData = await subAccount.encodeCalls(calls); const response = await fetch(`${SIMULATION_URL}/v1/simulation`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ evmTransaction: { account: subAccount.address, from: subAccount.address, to: subAccount.address, data: encodedData, value: "0", chainId: this.chain.id, }, }), }); const { tokensChanged } = await response.json(); const publicClient = createPublicClient({ chain: this.chain, transport: http(), }); const erc20Tokens: Token[] = tokensChanged .filter((t: TokenChanged) => !isAddressEqual(t.address, zeroAddress)) .map((t: TokenChanged) => ({ address: t.address, amount: BigInt(t.amount), chainId: t.chainId, })); const balances = (await publicClient.multicall({ allowFailure: false, contracts: erc20Tokens.flatMap((t) => { return [ { abi: erc20Abi, address: t.address, functionName: "balanceOf", args: [subAccount.address], }, { abi: erc20Abi, address: t.address, functionName: "balanceOf", args: [this.masterAccountAddress], }, ]; }), })) as [bigint]; let tokenPulled: Token[] = []; try { tokenPulled = erc20Tokens .map((t, index) => { const subBalance = balances[index]; const masterBalance = balances[index + 1]; // if the sub account has enough balance if (subBalance >= t.amount) { return { ...t, amount: 0n, }; } const pulledAmount = t.amount - subBalance; if (masterBalance < pulledAmount) { // no avilable token to pull, return empty calls throw new Error("No available token to pull"); } return { ...t, amount: pulledAmount, }; }) .filter((t: Token) => t.amount > 0n); } catch (err) { return []; } // If there are tokens to pull, show the approval iframe if (tokenPulled.length > 0) { await this.requestTokenApproval( this.masterAccountAddress, subAccount.address, tokenPulled, ); } const pullTokenCalls = await subAccount.encodeCalls( tokenPulled.map((t: Token) => ({ to: t.address, data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [subAccount.address, BigInt(t.amount)], }), })), ); const { args: [execMode, executionCallData], } = decodeFunctionData({ abi: KernelV3ExecuteAbi, data: pullTokenCalls, }); // return the pull token calls and the original calls return [ { to: this.masterAccountAddress as Address, data: encodeFunctionData({ abi: KernelV3ExecuteAbi, functionName: "executeFromExecutor", args: [execMode as Hex, executionCallData], }), }, ...calls, ]; } async requestTokenApproval( masterAccount: Address | null, subAccount: Address, tokens: Token[], ): Promise { if (!masterAccount) { throw new Error("No master account address"); } // Create an iframe for token approval const iframe = document.createElement("iframe"); // Format tokens data for URL params const tokensParam = encodeURIComponent( JSON.stringify( tokens.map((t) => ({ chainId: t.chainId, address: t.address, amount: t.amount.toString(), })), ), ); iframe.src = `${WALLET_URL}/sign?client_id=example_client_1&masterAccount=${masterAccount}&subAccount=${subAccount}&tokens=${tokensParam}&chainId=${this.chain.id}`; iframe.style.width = "600px"; iframe.style.height = "600px"; iframe.style.border = "none"; iframe.style.position = "fixed"; iframe.style.top = "50%"; iframe.style.left = "50%"; iframe.style.transform = "translate(-50%, -50%)"; iframe.style.zIndex = "9999"; iframe.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.15)"; iframe.style.borderRadius = "12px"; iframe.allow = "publickey-credentials-get; publickey-credentials-create"; // Create overlay background const overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.top = "0"; overlay.style.left = "0"; overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; overlay.style.zIndex = "9998"; // Add to the DOM document.body.appendChild(overlay); document.body.appendChild(iframe); // Helper function to safely remove elements const safelyRemoveElements = () => { if (iframe.parentNode) { iframe.parentNode.removeChild(iframe); } if (overlay.parentNode) { overlay.parentNode.removeChild(overlay); } }; // Return a promise that resolves when we receive the response return new Promise((resolve, reject) => { const handleMessage = async (event: MessageEvent) => { // Verify the origin for security if (event.origin !== WALLET_URL) return; try { // Process the response data const { approved, error } = event.data; // Clean up the iframe and overlay safelyRemoveElements(); if (error) { reject(new Error(error)); return; } if (!approved) { reject(new Error("Token approval rejected by user")); return; } // Resolve with the approval status resolve(approved); } catch (error) { reject(error); } finally { window.removeEventListener("message", handleMessage); } }; // Set up listener for the response window.addEventListener("message", handleMessage); // Add close functionality to the overlay overlay.addEventListener("click", () => { safelyRemoveElements(); window.removeEventListener("message", handleMessage); reject(new Error("Token approval cancelled by user")); }); }); } async requestToYiWallet(request: RequestArguments) { const requestParam = encodeURIComponent(JSON.stringify(request)); // Create an iframe for token approval const iframe = document.createElement("iframe"); // iframe.src = `${WALLET_URL}/test`; iframe.src = `${WALLET_URL}/passkey-signing?client_id=example_client_1&request=${requestParam}&chain_id=${this.chain.id}`; iframe.style.width = "600px"; iframe.style.height = "600px"; iframe.style.border = "none"; iframe.style.position = "fixed"; iframe.style.top = "50%"; iframe.style.left = "50%"; iframe.style.transform = "translate(-50%, -50%)"; iframe.style.zIndex = "9999"; iframe.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.15)"; iframe.style.borderRadius = "12px"; iframe.allow = "publickey-credentials-get; publickey-credentials-create"; // Create overlay background const overlay = document.createElement("div"); overlay.style.position = "fixed"; overlay.style.top = "0"; overlay.style.left = "0"; overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; overlay.style.zIndex = "9998"; // Add to the DOM document.body.appendChild(overlay); document.body.appendChild(iframe); // Helper function to safely remove elements const safelyRemoveElements = () => { if (iframe.parentNode) { iframe.parentNode.removeChild(iframe); } if (overlay.parentNode) { overlay.parentNode.removeChild(overlay); } }; // Return a promise that resolves when we receive the response return new Promise((resolve, reject) => { const handleMessage = async (event: MessageEvent) => { // Verify the origin for security if (event.origin !== WALLET_URL) return; try { // Process the response data const data = event.data; // Clean up the iframe and overlay safelyRemoveElements(); if (data.error) { reject(new Error(JSON.stringify(data.error))); return; } // Resolve with the approval status resolve(data); } catch (error) { reject(error); } finally { window.removeEventListener("message", handleMessage); } }; // Set up listener for the response window.addEventListener("message", handleMessage); // Add close functionality to the overlay overlay.addEventListener("click", () => { safelyRemoveElements(); window.removeEventListener("message", handleMessage); reject(new Error("Token approval cancelled by user")); }); }); } }