UNPKG

@coinbase/onchaintestkit

Version:

End-to-end testing toolkit for blockchain applications, powered by Playwright

512 lines (511 loc) 25.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CoinbaseWallet = exports.CoinbaseSpecificActionType = void 0; const node_path_1 = __importDefault(require("node:path")); const test_1 = require("@playwright/test"); const extensionManager_1 = require("../../utils/extensionManager"); const BaseWallet_1 = require("../BaseWallet"); const PasskeyAuthenticator_1 = require("./PasskeyAuthenticator"); const pages_1 = require("./pages"); // Extend BaseActionType with Coinbase-specific actions var CoinbaseSpecificActionType; (function (CoinbaseSpecificActionType) { CoinbaseSpecificActionType["LOCK"] = "lock"; CoinbaseSpecificActionType["UNLOCK"] = "unlock"; CoinbaseSpecificActionType["ADD_TOKEN"] = "addToken"; CoinbaseSpecificActionType["ADD_ACCOUNT"] = "addAccount"; CoinbaseSpecificActionType["SWITCH_ACCOUNT"] = "switchAccount"; CoinbaseSpecificActionType["ADD_NETWORK"] = "addNetwork"; CoinbaseSpecificActionType["SEND_TOKENS"] = "sendTokens"; CoinbaseSpecificActionType["HANDLE_PASSKEY_POPUP"] = "handlePasskeyPopup"; })(CoinbaseSpecificActionType || (exports.CoinbaseSpecificActionType = CoinbaseSpecificActionType = {})); const WALLET_CONNECTION_ERROR = new Error("Coinbase Wallet extension connection not established"); const COINBASE_VERSION = "3.117.1"; class CoinbaseWallet extends BaseWallet_1.BaseWallet { get authenticator() { return this.passkeyAuthenticator; } constructor(walletConfig, context, page, extensionId) { super(); // Passkey authenticator state this.passkeyAuthenticator = null; this.passkeyCredentials = []; this.context = context; this.extensionId = extensionId; this.config = walletConfig; this.page = page; this.onboardingPage = new pages_1.OnboardingPage(page); this.homePage = new pages_1.HomePage(page); this.notificationPage = new pages_1.NotificationPage(page); } static async initialize(currentContext, contextPath, _walletConfig) { // Create browser context with Coinbase extension const context = await CoinbaseWallet.createContext(contextPath); // Handle cookie and storage transfer if currentContext exists if (currentContext) { const { cookies } = await currentContext.storageState(); if (cookies) { await context.addCookies(cookies); } } // Wait for extension page to load and get extension ID const extensionId = await (0, extensionManager_1.getExtensionId)(context, "Coinbase Wallet extension"); // Get the extension page using the correct path from manifest.json const extensionUrl = `chrome-extension://${extensionId}/index.html?inPageRequest=false`; const coinbasePage = await context.newPage(); await coinbasePage.goto(extensionUrl, { waitUntil: "domcontentloaded" }); // Wait for extension to be ready await coinbasePage.waitForLoadState("networkidle"); // Close any other pages const pages = context.pages(); for (const page of pages) { if (page !== coinbasePage) { await page.close(); } } return { coinbasePage, coinbaseContext: context }; } static async createContext(contextPath, slowMo = 0) { // Use retry logic to handle potential race conditions when setting up extension const MAX_RETRIES = 3; let lastError = null; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { if (attempt > 0) { console.log(`Retrying Coinbase setup (attempt ${attempt + 1}/${MAX_RETRIES})...`); } // Get coinbase extension path (assumes prepare-coinbase.mjs was run first) const cacheDir = node_path_1.default.join(process.cwd(), "e2e", ".cache", "coinbase-extension"); const coinbasePath = node_path_1.default.join(cacheDir, `coinbase-${COINBASE_VERSION}`); console.log("Coinbase extension prepared at:", coinbasePath); const browserArgs = [ `--disable-extensions-except=${coinbasePath}`, "--enable-extensions", "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-accelerated-2d-canvas", "--no-first-run", "--no-zygote", "--disable-gpu", "--force-gpu-mem-available-mb=1024", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--disable-features=IsolateOrigins,site-per-process", // Allow cross-origin iframes ]; const context = await test_1.chromium.launchPersistentContext(contextPath, { headless: false, args: browserArgs, slowMo, viewport: { width: 1280, height: 800 }, ignoreHTTPSErrors: true, acceptDownloads: true, }); // Wait for extension to be ready await new Promise(resolve => setTimeout(resolve, 2000)); return context; } catch (error) { // If we failed at the setup stage if (attempt === MAX_RETRIES - 1) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to setup Coinbase: ${errorMessage}`); } // Save error for potential retry lastError = error instanceof Error ? error : new Error(String(error)); // Short delay before retry await new Promise(resolve => setTimeout(resolve, 2000)); } } // If we reach here, all attempts failed throw lastError ?? new Error("Failed to create context after all retries"); } /** * Handle a passkey popup for registration or authentication. * For registration, receives the main page and the first popup, clicks the switch link, waits for the second popup, and registers there. * For approve, receives the transaction popup directly. * @param mainPage The Playwright Page for the main dapp (only needed for registration) * @param popup The Playwright Page for the popup (first popup for registration, transaction popup for approve) * @param action 'register' or 'approve' * @param config PasskeyConfig (required for 'register') */ async handlePasskeyPopup(mainPageOrPopup, popup, action, config) { if (action === "registerWithCBExtension") { const firstPopup = popup; const [secondPopup] = await Promise.all([ mainPageOrPopup.context().waitForEvent("page"), firstPopup.click('[data-testid="switch-to-scw-link"]'), ]); await secondPopup.waitForLoadState("domcontentloaded"); await secondPopup.waitForSelector('button:has-text("Create an account")', { timeout: 10000, }); if (this.passkeyAuthenticator) { await this.passkeyAuthenticator.setPage(secondPopup); } else { this.passkeyAuthenticator = new PasskeyAuthenticator_1.PasskeyAuthenticator(secondPopup); } if (!config) throw new Error("PasskeyConfig required for registration"); await this.passkeyAuthenticator.initialize({ protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: config.isUserVerified ?? true, automaticPresenceSimulation: true, }); await this.passkeyAuthenticator.simulateSuccessfulPasskeyInput(async () => { await secondPopup .locator('button:has-text("Create an account")') .click(); await secondPopup .locator('[data-testid="passkey-name-input"]') .fill(config.name); await secondPopup.locator('[data-testid="continue-button"]').click(); }); this.passkeyCredentials = await this.passkeyAuthenticator.exportCredentials(); } else if (action === "registerWithSmartWalletSDK") { const sdkPopup = popup; await sdkPopup.waitForLoadState("domcontentloaded"); await sdkPopup.waitForSelector('button:has-text("Create an account")', { timeout: 10000, }); if (this.passkeyAuthenticator) { await this.passkeyAuthenticator.setPage(sdkPopup); } else { this.passkeyAuthenticator = new PasskeyAuthenticator_1.PasskeyAuthenticator(sdkPopup); } if (!config) throw new Error("PasskeyConfig required for registration"); await this.passkeyAuthenticator.initialize({ protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: config.isUserVerified ?? true, automaticPresenceSimulation: true, }); await this.passkeyAuthenticator.simulateSuccessfulPasskeyInput(async () => { await sdkPopup.locator('button:has-text("Create an account")').click(); await sdkPopup .locator('[data-testid="passkey-name-input"]') .fill(config.name); await sdkPopup.locator('[data-testid="continue-button"]').click(); }); const creds = await this.passkeyAuthenticator.exportCredentials(); this.passkeyCredentials = creds; } else if (action === "signMessage") { const signPopup = popup; await signPopup.waitForLoadState("domcontentloaded"); // Wait for the popup to reach the expected URL const expectedUrl = "https://keys.coinbase.com/sign/personal-sign"; let currentUrl = await signPopup.url(); if (!String(currentUrl).startsWith(expectedUrl)) { await signPopup.waitForURL(url => String(url).startsWith(expectedUrl), { timeout: 15000, }); currentUrl = await signPopup.url(); } if (this.passkeyAuthenticator) { await this.passkeyAuthenticator.setPage(signPopup); } else { this.passkeyAuthenticator = new PasskeyAuthenticator_1.PasskeyAuthenticator(signPopup); } if (!config) throw new Error("PasskeyConfig required for signMessage"); await this.passkeyAuthenticator.initialize({ protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: config.isUserVerified ?? true, automaticPresenceSimulation: true, }); for (const cred of this.passkeyCredentials) { await this.passkeyAuthenticator.importCredential(cred); } await popup.waitForLoadState("domcontentloaded"); await popup.waitForLoadState("networkidle"); await this.passkeyAuthenticator.simulateSuccessfulPasskeyInput(async () => { await signPopup.waitForLoadState("domcontentloaded"); await signPopup.locator('[data-testid="button-1"]').click(); }); } else if (action === "grantSpendPermission") { // Handle grant spend permission popup if (this.passkeyAuthenticator) { await this.passkeyAuthenticator.setPage(popup); } else { this.passkeyAuthenticator = new PasskeyAuthenticator_1.PasskeyAuthenticator(popup); } await popup.waitForURL(url => String(url).startsWith("https://keys-dev.coinbase.com/sign/grant-permission"), { timeout: 15000 }); await this.passkeyAuthenticator.initialize({ protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }); const hardcodedCredential = { credentialId: "LWE8QFe2si8y58AgG8o6DLJs4ZIKNnpD0/7NEsuBQnw=", isResidentCredential: true, rpId: "keys-dev.coinbase.com", privateKey: "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTEa+O0Uztk3hi65nPXaaL4idLccOOlqCzBSiv+COKAuhRANCAAT2fO9Pi6ZnTi7LY2zUnHbyCuJFq/wMn+C864QzQcwwqFj7W++4QLMubCeKqZXAjs3q3F4hr2Q1arqpmNW75uwS", userHandle: "MTNjNzc3NjgtMmZlMC00NGZjLTk1MGMtMWViMjdjOWNmNmI0", signCount: 1, }; await this.passkeyAuthenticator.importCredential(hardcodedCredential); await popup.waitForLoadState("domcontentloaded"); await popup.waitForLoadState("networkidle"); await this.passkeyAuthenticator.simulateSuccessfulPasskeyInput(async () => { await popup.getByTestId("grant-permissions-approve-button").click(); }); } else if (action === "approve") { if (this.passkeyAuthenticator) { await this.passkeyAuthenticator.setPage(popup); } else { this.passkeyAuthenticator = new PasskeyAuthenticator_1.PasskeyAuthenticator(popup); } // Wait for the popup to reach the expected URL const expectedUrls = [ "https://keys.coinbase.com/sign/wallet-send-calls", "https://keys.coinbase.com/sign/eth-send-transaction", ]; let currentUrl = await popup.url(); if (!expectedUrls.some(prefix => String(currentUrl).startsWith(prefix))) { await popup.waitForURL(url => expectedUrls.some(prefix => String(url).startsWith(prefix)), { timeout: 15000 }); currentUrl = await popup.url(); } await this.passkeyAuthenticator.initialize({ protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }); // Import credentials from registration for (const cred of this.passkeyCredentials) { await this.passkeyAuthenticator.importCredential(cred); } await popup.waitForLoadState("domcontentloaded"); await popup.waitForLoadState("networkidle"); await this.passkeyAuthenticator.simulateSuccessfulPasskeyInput(async () => { await popup .locator('[data-testid="approve-transaction-button"]') .click(); }); } else if (action === "signIn") { const sdkPopup = popup; await sdkPopup.waitForLoadState("domcontentloaded"); await sdkPopup.waitForSelector('button:has-text("Sign in")', { timeout: 10000, }); if (this.passkeyAuthenticator) { await this.passkeyAuthenticator.setPage(sdkPopup); } else { this.passkeyAuthenticator = new PasskeyAuthenticator_1.PasskeyAuthenticator(sdkPopup); } await this.passkeyAuthenticator.initialize({ protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }); const hardcodedCredential = { credentialId: "LWE8QFe2si8y58AgG8o6DLJs4ZIKNnpD0/7NEsuBQnw=", isResidentCredential: true, rpId: "keys-dev.coinbase.com", privateKey: "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTEa+O0Uztk3hi65nPXaaL4idLccOOlqCzBSiv+COKAuhRANCAAT2fO9Pi6ZnTi7LY2zUnHbyCuJFq/wMn+C864QzQcwwqFj7W++4QLMubCeKqZXAjs3q3F4hr2Q1arqpmNW75uwS", userHandle: "MTNjNzc3NjgtMmZlMC00NGZjLTk1MGMtMWViMjdjOWNmNmI0", signCount: 1, }; await this.passkeyAuthenticator.importCredential(hardcodedCredential); await sdkPopup.waitForLoadState("domcontentloaded"); await sdkPopup.waitForLoadState("networkidle"); await this.passkeyAuthenticator.simulateSuccessfulPasskeyInput(async () => { await sdkPopup.locator('button:has-text("Sign in")').click(); }); await sdkPopup .getByRole("button", { name: "Continue", exact: true }) .click(); } else if (action === "signInWithCBExtension") { const firstPopup = popup; const [sdkPopup] = await Promise.all([ mainPageOrPopup.context().waitForEvent("page"), firstPopup.click('[data-testid="switch-to-scw-link"]'), ]); await sdkPopup.waitForLoadState("domcontentloaded"); await sdkPopup.waitForSelector('button:has-text("Sign in")', { timeout: 10000, }); if (this.passkeyAuthenticator) { await this.passkeyAuthenticator.setPage(sdkPopup); } else { this.passkeyAuthenticator = new PasskeyAuthenticator_1.PasskeyAuthenticator(sdkPopup); } await this.passkeyAuthenticator.initialize({ protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }); const hardcodedCredential = { credentialId: "LWE8QFe2si8y58AgG8o6DLJs4ZIKNnpD0/7NEsuBQnw=", isResidentCredential: true, rpId: config?.rpId ?? "keys-dev.coinbase.com", privateKey: "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTEa+O0Uztk3hi65nPXaaL4idLccOOlqCzBSiv+COKAuhRANCAAT2fO9Pi6ZnTi7LY2zUnHbyCuJFq/wMn+C864QzQcwwqFj7W++4QLMubCeKqZXAjs3q3F4hr2Q1arqpmNW75uwS", userHandle: "MTNjNzc3NjgtMmZlMC00NGZjLTk1MGMtMWViMjdjOWNmNmI0", signCount: 1, }; await this.passkeyAuthenticator.importCredential(hardcodedCredential); await sdkPopup.waitForLoadState("domcontentloaded"); await sdkPopup.waitForLoadState("networkidle"); await this.passkeyAuthenticator.simulateSuccessfulPasskeyInput(async () => { await sdkPopup.locator('button:has-text("Sign in")').click(); }); await sdkPopup .getByRole("button", { name: "Continue", exact: true }) .click(); } else { throw new Error(`Unknown passkey popup action: ${action}`); } } async handleAction(action, options) { const { approvalType, ...additionalOptions } = options ?? {}; if (!this.extensionId) { throw WALLET_CONNECTION_ERROR; } // Passkey popup handling if (action === CoinbaseSpecificActionType.HANDLE_PASSKEY_POPUP) { // expects: options.mainPage, options.popup, options.passkeyAction, options.passkeyConfig const mainPage = additionalOptions.mainPage; const popup = additionalOptions.popup; const passkeyAction = additionalOptions.passkeyAction; const passkeyConfig = additionalOptions.passkeyConfig; await this.handlePasskeyPopup(mainPage, popup, passkeyAction, passkeyConfig); return; } switch (action) { // Basic setup actions case BaseWallet_1.BaseActionType.IMPORT_WALLET_FROM_SEED: await this.onboardingPage.importWallet(additionalOptions.seedPhrase, additionalOptions.password); break; case BaseWallet_1.BaseActionType.IMPORT_WALLET_FROM_PRIVATE_KEY: await this.homePage.importPrivateKey(additionalOptions.privateKey, this.config.password); break; // Network actions case CoinbaseSpecificActionType.ADD_NETWORK: if (!additionalOptions.network) { throw new Error("Network options not provided for ADD_NETWORK action"); } await this.homePage.addNetwork(additionalOptions.network); break; case BaseWallet_1.BaseActionType.SWITCH_NETWORK: await this.homePage.switchNetwork(additionalOptions.networkName, additionalOptions.isTestnet); break; // Account actions case CoinbaseSpecificActionType.ADD_ACCOUNT: await this.homePage.addNewAccount(additionalOptions.accountName); break; case CoinbaseSpecificActionType.SWITCH_ACCOUNT: await this.homePage.switchAccount(additionalOptions.accountName); break; // Dapp actions case BaseWallet_1.BaseActionType.CONNECT_TO_DAPP: await this.notificationPage.connectToDapp(this.extensionId); break; // Transaction actions case BaseWallet_1.BaseActionType.HANDLE_TRANSACTION: if (approvalType === BaseWallet_1.ActionApprovalType.APPROVE) { await this.notificationPage.confirmTransaction(this.extensionId); } else { await this.notificationPage.rejectTransaction(this.extensionId); } break; // Token permission actions case BaseWallet_1.BaseActionType.CHANGE_SPENDING_CAP: if (approvalType === BaseWallet_1.ActionApprovalType.APPROVE) { await this.notificationPage.approveTokenPermission(this.extensionId); } else { await this.notificationPage.rejectTokenPermission(this.extensionId); } break; case BaseWallet_1.BaseActionType.REMOVE_SPENDING_CAP: if (approvalType === BaseWallet_1.ActionApprovalType.APPROVE) { await this.notificationPage.confirmSpendingCapRemoval(this.extensionId); } else { await this.notificationPage.rejectSpendingCapRemoval(this.extensionId); } break; case CoinbaseSpecificActionType.SEND_TOKENS: // TODO: Implement token sending // if (!additionalOptions.recipientAddress || !additionalOptions.amount) { // throw new Error( // "Recipient address and amount are required for sending tokens", // ) // } // await this.homePage.sendTokens( // additionalOptions.recipientAddress as string, // additionalOptions.amount as string, // additionalOptions.tokenSymbol as string | undefined, // ) throw new Error("sendTokens not implemented for Coinbase Wallet"); default: throw new Error(`Unsupported action: ${action}`); } } // Public getters for SmartWallet integration get walletContext() { return this.context; } get walletPage() { return this.page; } get walletExtensionId() { return this.extensionId; } } exports.CoinbaseWallet = CoinbaseWallet; __exportStar(require("./fixtures"), exports);