@coinbase/onchaintestkit
Version:
End-to-end testing toolkit for blockchain applications, powered by Playwright
512 lines (511 loc) • 25.4 kB
JavaScript
"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);